mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Normalize every provider to one cache-inclusive convention via canonicalizeUsage() before persist, and price cached + cache_creation as subsets of prompt_tokens in calculateCostFromTokens() to stop double-counting. usageRepo now delegates cost math to a single source. Surface Cached tokens/cost across dashboard (overview, tokens, cost, details). Merge Claude message_start cache with message_delta output so cache counts survive. Compatible LLM nodes now allow multiple API-key connections (key pool). Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
8.0 KiB
JavaScript
196 lines
8.0 KiB
JavaScript
import { register } from "../index.js";
|
|
import { FORMATS } from "../formats.js";
|
|
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK, OPENAI_FINISH } from "../schema/index.js";
|
|
import { buildChunk } from "../concerns/chunk.js";
|
|
import { toOpenAIUsage } from "../concerns/usage.js";
|
|
import { reasoningDelta } from "../concerns/reasoning.js";
|
|
import { toOpenAIFinish } from "../concerns/finishReason.js";
|
|
|
|
// Create OpenAI chunk helper
|
|
function createChunk(state, delta, finishReason = null) {
|
|
return buildChunk(
|
|
{ id: `chatcmpl-${state.messageId}`, created: Math.floor(Date.now() / 1000), model: state.model },
|
|
delta,
|
|
finishReason
|
|
);
|
|
}
|
|
|
|
// Convert Claude stream chunk to OpenAI format
|
|
export function claudeToOpenAIResponse(chunk, state) {
|
|
if (!chunk) return null;
|
|
|
|
const results = [];
|
|
const event = chunk.type;
|
|
|
|
switch (event) {
|
|
case "message_start": {
|
|
state.messageId = chunk.message?.id || `msg_${Date.now()}`;
|
|
state.model = chunk.message?.model;
|
|
state.toolCallIndex = 0;
|
|
// Claude sends input_tokens + cache_read + cache_creation here; message_delta
|
|
// later carries only the final output_tokens. Capture cache now so the
|
|
// delta (output-only) doesn't reset it to zero.
|
|
const startUsage = chunk.message?.usage;
|
|
if (startUsage && typeof startUsage === "object") {
|
|
const inputTokens = typeof startUsage.input_tokens === "number" ? startUsage.input_tokens : 0;
|
|
const cacheReadTokens = typeof startUsage.cache_read_input_tokens === "number" ? startUsage.cache_read_input_tokens : 0;
|
|
const cacheCreationTokens = typeof startUsage.cache_creation_input_tokens === "number" ? startUsage.cache_creation_input_tokens : 0;
|
|
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
|
state.usage = {
|
|
prompt_tokens: promptTokens,
|
|
completion_tokens: 0,
|
|
total_tokens: promptTokens,
|
|
input_tokens: inputTokens,
|
|
output_tokens: 0
|
|
};
|
|
if (cacheReadTokens > 0) state.usage.cache_read_input_tokens = cacheReadTokens;
|
|
if (cacheCreationTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationTokens;
|
|
}
|
|
results.push(createChunk(state, { role: ROLE.ASSISTANT }));
|
|
break;
|
|
}
|
|
|
|
case "content_block_start": {
|
|
const block = chunk.content_block;
|
|
if (block?.type === "server_tool_use") {
|
|
// Built-in tool (web search) - Claude handles internally, skip
|
|
state.serverToolBlockIndex = chunk.index;
|
|
break;
|
|
}
|
|
if (block?.type === CLAUDE_BLOCK.TEXT) {
|
|
state.textBlockStarted = true;
|
|
} else if (block?.type === CLAUDE_BLOCK.THINKING) {
|
|
state.inThinkingBlock = true;
|
|
state.currentBlockIndex = chunk.index;
|
|
results.push(createChunk(state, { content: "<think>" }));
|
|
} else if (block?.type === CLAUDE_BLOCK.TOOL_USE) {
|
|
const toolCallIndex = state.toolCallIndex++;
|
|
// Restore original tool name from mapping (Claude OAuth)
|
|
const toolName = state.toolNameMap?.get(block.name) || block.name;
|
|
const toolCall = {
|
|
index: toolCallIndex,
|
|
id: block.id,
|
|
type: OPENAI_BLOCK.FUNCTION,
|
|
function: {
|
|
name: toolName,
|
|
arguments: ""
|
|
}
|
|
};
|
|
state.toolCalls.set(chunk.index, toolCall);
|
|
results.push(createChunk(state, { tool_calls: [toolCall] }));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "content_block_delta": {
|
|
// Skip deltas for built-in server tool blocks (web search)
|
|
if (chunk.index === state.serverToolBlockIndex) break;
|
|
const delta = chunk.delta;
|
|
if (delta?.type === "text_delta" && delta.text) {
|
|
results.push(createChunk(state, { content: delta.text }));
|
|
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
results.push(createChunk(state, reasoningDelta(delta.thinking)));
|
|
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
|
|
const toolCall = state.toolCalls.get(chunk.index);
|
|
if (toolCall) {
|
|
toolCall.function.arguments += delta.partial_json;
|
|
results.push(createChunk(state, {
|
|
tool_calls: [{
|
|
index: toolCall.index,
|
|
id: toolCall.id,
|
|
function: { arguments: delta.partial_json }
|
|
}]
|
|
}));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "content_block_stop": {
|
|
// Skip stop for built-in server tool blocks (web search)
|
|
if (chunk.index === state.serverToolBlockIndex) {
|
|
state.serverToolBlockIndex = -1;
|
|
break;
|
|
}
|
|
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
|
|
results.push(createChunk(state, { content: "</think>" }));
|
|
state.inThinkingBlock = false;
|
|
}
|
|
state.textBlockStarted = false;
|
|
state.thinkingBlockStarted = false;
|
|
break;
|
|
}
|
|
|
|
case "message_delta": {
|
|
// Extract usage from message_delta event (Claude native format).
|
|
// Anthropic sends input/cache in message_start and only output here, so
|
|
// fall back to cache captured in message_start when the delta omits it.
|
|
if (chunk.usage && typeof chunk.usage === "object") {
|
|
const prev = state.usage || {};
|
|
const inputTokens = typeof chunk.usage.input_tokens === "number" ? chunk.usage.input_tokens : (prev.input_tokens || 0);
|
|
const outputTokens = typeof chunk.usage.output_tokens === "number" ? chunk.usage.output_tokens : 0;
|
|
const cacheReadTokens = typeof chunk.usage.cache_read_input_tokens === "number" ? chunk.usage.cache_read_input_tokens : (prev.cache_read_input_tokens || 0);
|
|
const cacheCreationTokens = typeof chunk.usage.cache_creation_input_tokens === "number" ? chunk.usage.cache_creation_input_tokens : (prev.cache_creation_input_tokens || 0);
|
|
|
|
// prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens)
|
|
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
|
|
|
state.usage = {
|
|
prompt_tokens: promptTokens,
|
|
completion_tokens: outputTokens,
|
|
total_tokens: promptTokens + outputTokens,
|
|
input_tokens: inputTokens,
|
|
output_tokens: outputTokens
|
|
};
|
|
|
|
if (cacheReadTokens > 0) state.usage.cache_read_input_tokens = cacheReadTokens;
|
|
if (cacheCreationTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationTokens;
|
|
}
|
|
|
|
if (chunk.delta?.stop_reason) {
|
|
state.finishReason = convertStopReason(chunk.delta.stop_reason);
|
|
const finalChunk = createChunk(state, {}, state.finishReason);
|
|
|
|
if (state.usage) {
|
|
// Build OpenAI usage from the merged state (cache from message_start +
|
|
// output from message_delta), not the delta chunk alone.
|
|
finalChunk.usage = toOpenAIUsage({
|
|
input_tokens: state.usage.input_tokens || 0,
|
|
output_tokens: state.usage.output_tokens || 0,
|
|
cache_read_input_tokens: state.usage.cache_read_input_tokens,
|
|
cache_creation_input_tokens: state.usage.cache_creation_input_tokens
|
|
}, "claude");
|
|
}
|
|
|
|
results.push(finalChunk);
|
|
state.finishReasonSent = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "message_stop": {
|
|
if (!state.finishReasonSent) {
|
|
const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? OPENAI_FINISH.TOOL_CALLS : OPENAI_FINISH.STOP);
|
|
const usageObj = (state.usage && typeof state.usage === 'object') ? {
|
|
usage: {
|
|
prompt_tokens: state.usage.input_tokens || 0,
|
|
completion_tokens: state.usage.output_tokens || 0,
|
|
total_tokens: (state.usage.input_tokens || 0) + (state.usage.output_tokens || 0)
|
|
}
|
|
} : {};
|
|
results.push({ ...createChunk(state, {}, finishReason), ...usageObj });
|
|
state.finishReasonSent = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return results.length > 0 ? results : null;
|
|
}
|
|
|
|
const convertStopReason = (reason) => toOpenAIFinish(reason, "claude");
|
|
|
|
// Register
|
|
register(FORMATS.CLAUDE, FORMATS.OPENAI, null, claudeToOpenAIResponse);
|
|
|