diff --git a/changelog.d/fixes/12863-gemini-to-claude-cached-input-tokens.md b/changelog.d/fixes/12863-gemini-to-claude-cached-input-tokens.md new file mode 100644 index 0000000000..915d5ffa68 --- /dev/null +++ b/changelog.d/fixes/12863-gemini-to-claude-cached-input-tokens.md @@ -0,0 +1 @@ +- **fix(translator):** Gemini to Claude usage no longer double-counts the cached prompt prefix — `input_tokens` now excludes `cache_read_input_tokens`, matching the Anthropic Messages semantics ([#12863](https://github.com/diegosouzapw/OmniRoute/pull/12863)) diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 8b06d8707a..523f55d6b1 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -397,7 +397,7 @@ export function geminiToClaudeResponse(chunk, state) { typeof usageMeta.cachedContentTokenCount === "number" ? usageMeta.cachedContentTokenCount : 0; state.usage = { - input_tokens: inputTokens, + input_tokens: Math.max(0, inputTokens - cachedTokens), output_tokens: candidatesTokens + thoughtsTokens, }; if (cachedTokens > 0) { diff --git a/tests/unit/translator-resp-gemini-to-claude.test.ts b/tests/unit/translator-resp-gemini-to-claude.test.ts index 4e63824842..85917db19b 100644 --- a/tests/unit/translator-resp-gemini-to-claude.test.ts +++ b/tests/unit/translator-resp-gemini-to-claude.test.ts @@ -107,7 +107,7 @@ test("Gemini -> Claude stream: functionCall becomes tool_use and MAX_TOKENS maps assert.equal(result[2].delta.partial_json, JSON.stringify({ path: "/tmp/a" })); assert.equal(result[3].type, "content_block_stop"); assert.equal(result[4].delta.stop_reason, "tool_use"); - assert.equal(result[4].usage.input_tokens, 5); + assert.equal(result[4].usage.input_tokens, 4); assert.equal(result[4].usage.output_tokens, 5); assert.equal(result[4].usage.cache_read_input_tokens, 1); assert.equal(result[5].type, "message_stop"); @@ -190,3 +190,52 @@ test("Gemini -> Claude stream: response wrapper is supported and promptFeedback- assert.equal(wrapped[2].delta.text, "wrapped"); assert.equal(geminiToClaudeResponse({ promptFeedback: { blockReason: "SAFETY" } }, {}), null); }); + +test("Gemini -> Claude stream: input_tokens excludes cached tokens", () => { + const result = geminiToClaudeResponse( + { + responseId: "resp-6", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "done" }] }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 7, + cachedContentTokenCount: 90, + }, + }, + {} + ); + + const delta = result.find((event) => event.type === "message_delta"); + assert.equal(delta.usage.input_tokens, 10); + assert.equal(delta.usage.cache_read_input_tokens, 90); +}); + +test("Gemini -> Claude stream: input_tokens equals promptTokenCount when nothing is cached", () => { + const result = geminiToClaudeResponse( + { + responseId: "resp-7", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "done" }] }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 7, + }, + }, + {} + ); + + const delta = result.find((event) => event.type === "message_delta"); + assert.equal(delta.usage.input_tokens, 100); + assert.equal(delta.usage.cache_read_input_tokens, undefined); +});