Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
94f5fe59fa fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536) 2026-08-06 20:59:56 -03:00
3 changed files with 191 additions and 4 deletions

View File

@@ -0,0 +1 @@
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)

View File

@@ -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;

View File

@@ -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<string, unknown>).usage as Record<string, unknown>;
// 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<string, unknown>).usage as Record<string, unknown>;
// 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<string, unknown>).usage as Record<string, unknown>;
// 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<string, unknown>).usage as Record<string, unknown>;
// 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<string, unknown>).usage as Record<string, unknown>;
// 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");
});
});