diff --git a/changelog.d/fixes/9536-usage-misreporting.md b/changelog.d/fixes/9536-usage-misreporting.md new file mode 100644 index 0000000000..ad6daa28ee --- /dev/null +++ b/changelog.d/fixes/9536-usage-misreporting.md @@ -0,0 +1 @@ +- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536) diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 35f66e1087..d157537db2 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -687,6 +687,35 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco if (stopReason === "tool_calls") stopReason = "tool_use"; const usageSrc = toRecord(openaiResponse.usage); + const promptTokens = toNumber(usageSrc.prompt_tokens, 0); + const outputTokens = toNumber(usageSrc.completion_tokens, 0); + + // Extract cache tokens from prompt_tokens_details (mirrors the streaming + // translator in open-sse/translator/response/openai-to-claude.ts lines 119-148). + const promptDetails = toRecord(usageSrc.prompt_tokens_details); + const cachedTokens = toNumber(promptDetails.cached_tokens, 0); + const cacheCreationTokens = toNumber(promptDetails.cache_creation_tokens, 0); + + // OpenAI's prompt_tokens includes all prompt-side tokens (cached + non-cached). + // Claude expects input_tokens to be only non-cached tokens, with cached tokens + // exposed separately as cache_read_input_tokens. + const inputTokens = promptTokens - cachedTokens - cacheCreationTokens; + + const usage: JsonRecord = { + input_tokens: inputTokens, + output_tokens: outputTokens, + }; + + // Add cache_read_input_tokens if present + if (cachedTokens > 0) { + usage.cache_read_input_tokens = cachedTokens; + } + + // Add cache_creation_input_tokens if present + if (cacheCreationTokens > 0) { + usage.cache_creation_input_tokens = cacheCreationTokens; + } + const claudeResponse: JsonRecord = { id: toString(openaiResponse.id, `msg_${Date.now()}`), type: "message", @@ -695,10 +724,7 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco content, stop_reason: stopReason, stop_sequence: null, - usage: { - input_tokens: toNumber(usageSrc.prompt_tokens, 0), - output_tokens: toNumber(usageSrc.completion_tokens, 0), - }, + usage, }; return claudeResponse; diff --git a/tests/unit/9536-usage-misreporting-openai-to-claude.test.ts b/tests/unit/9536-usage-misreporting-openai-to-claude.test.ts new file mode 100644 index 0000000000..0605a54c56 --- /dev/null +++ b/tests/unit/9536-usage-misreporting-openai-to-claude.test.ts @@ -0,0 +1,160 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +/** + * Test for #9536: Usage misreported on OpenAI-shaped upstreams when translating + * to Claude format (non-streaming path). + * + * Two defects: + * 1. cache_read_input_tokens is always 0 (missing mapping) + * 2. input_tokens is inflated by cached tokens (not subtracting prompt_tokens_details.cached_tokens) + * + * Plus regression guard for #8331 (buffer isolation via context_budget_* fields). + */ + +const DEEPSEEK_OPENAI_RESPONSE = { + id: "chatcmpl-deepseek-abc123", + object: "chat.completion", + model: "deepseek/deepseek-v4-flash", + choices: [ + { + index: 0, + message: { role: "assistant", content: "I am an AI assistant." }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 4364, + prompt_tokens_details: { cached_tokens: 4352 }, + prompt_cache_hit_tokens: 4352, + prompt_cache_miss_tokens: 12, + completion_tokens: 27, + total_tokens: 4391, + }, +}; + +const DEEPSEEK_OPENAI_RESPONSE_NO_CACHE = { + id: "chatcmpl-deepseek-no-cache", + object: "chat.completion", + model: "deepseek/deepseek-v4-flash", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello." }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 125, + prompt_tokens_details: {}, + completion_tokens: 5, + total_tokens: 130, + }, +}; + +/** + * OpenAI response that (before #8331's context_budget_* fix) would have had + * input_tokens += buffer. After #8331, the buffer values go into + * context_budget_* fields that filterUsageForFormat strips. + */ +const RESPONSE_WITH_BUFFER = { + id: "chatcmpl-buffer-test", + object: "chat.completion", + model: "gpt-4o", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello." }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 50, + completion_tokens: 10, + total_tokens: 60, + }, +}; + +describe("9536 - usage misreporting OpenAI->Claude (non-streaming)", () => { + it("Defect 1: cache_read_input_tokens should be present when cached_tokens > 0", () => { + const result = translateNonStreamingResponse( + DEEPSEEK_OPENAI_RESPONSE, + FORMATS.OPENAI, + FORMATS.CLAUDE + ); + + const usage = (result as Record).usage as Record; + + // cache_read_input_tokens should be mapped from prompt_tokens_details.cached_tokens + assert.equal( + usage.cache_read_input_tokens, + 4352, + `cache_read_input_tokens = ${usage.cache_read_input_tokens} (expected 4352)` + ); + }); + + it("Defect 2: input_tokens should be prompt_tokens minus cached tokens", () => { + const result = translateNonStreamingResponse( + DEEPSEEK_OPENAI_RESPONSE, + FORMATS.OPENAI, + FORMATS.CLAUDE + ); + + const usage = (result as Record).usage as Record; + + // input_tokens = prompt_tokens(4364) - cached_tokens(4352) = 12 + assert.equal(usage.input_tokens, 12, `input_tokens = ${usage.input_tokens} (expected 12)`); + }); + + it("Regression guard #8331: buffer should NOT inflate input_tokens", () => { + const result = translateNonStreamingResponse( + RESPONSE_WITH_BUFFER, + FORMATS.OPENAI, + FORMATS.CLAUDE + ); + + const usage = (result as Record).usage as Record; + + // input_tokens should be exactly prompt_tokens (50), no buffer added + assert.equal(usage.input_tokens, 50, `input_tokens = ${usage.input_tokens} (expected 50)`); + + // No context_budget_* fields should leak into the translated response + assert.equal(usage.context_budget_remaining, undefined); + assert.equal(usage.context_budget_consume, undefined); + assert.equal(usage.context_budget_add, undefined); + }); + + it("No cache data: input_tokens unchanged, no cache_read_input_tokens", () => { + const result = translateNonStreamingResponse( + DEEPSEEK_OPENAI_RESPONSE_NO_CACHE, + FORMATS.OPENAI, + FORMATS.CLAUDE + ); + + const usage = (result as Record).usage as Record; + + // Without cached_tokens, input_tokens = prompt_tokens = 125 + assert.equal(usage.input_tokens, 125, `input_tokens = ${usage.input_tokens} (expected 125)`); + + // cache_read_input_tokens should NOT be present when there's no caching + assert.equal(usage.cache_read_input_tokens, undefined); + }); + + it("Pass-through: same format returns usage unchanged", () => { + // When source === target, the function returns the response as-is + const result = translateNonStreamingResponse( + DEEPSEEK_OPENAI_RESPONSE, + FORMATS.OPENAI, + FORMATS.OPENAI + ); + + const usage = (result as Record).usage as Record; + + // OpenAI format should preserve all fields, including cached_tokens + assert.equal(usage.prompt_tokens, 4364); + assert.equal(usage.completion_tokens, 27); + assert.ok(usage.prompt_tokens_details, "prompt_tokens_details should be preserved"); + }); +});