diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b9dbb8ea..8337c731d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - **fix(auth):** accept `x-api-key` header in `extractApiKey` so Anthropic-native clients (Claude Code, `@anthropic-ai/sdk`) hit the same per-key policy enforcement as Bearer clients. Previously these requests were treated as anonymous, bypassing model/budget/rate-limit policies and showing up as `NULL` in `usage_history.api_key_id` (~50% of traffic invisible in Costs/Analytics). `Authorization: Bearer` still wins when both are present (back-compat). (#2225) +- **fix(translator/claude-to-openai):** stop including `cache_creation_input_tokens` in `prompt_tokens`. Anthropic pads short prompts up to a 1024-token minimum on cache creation, so a 2-token `"hi"` could be reported as ~2008 `prompt_tokens` and inflate downstream billing (Sub2API/NewAPI/OneAPI) ~250x. `prompt_tokens` now matches the dashboard "Total In" (`input + cache_read`); `cache_creation_tokens` is exposed separately in `prompt_tokens_details.cache_creation_tokens` for auditing. (#2215) ### Changed diff --git a/open-sse/translator/response/claude-to-openai.ts b/open-sse/translator/response/claude-to-openai.ts index 79f2a44796..76126cface 100644 --- a/open-sse/translator/response/claude-to-openai.ts +++ b/open-sse/translator/response/claude-to-openai.ts @@ -129,12 +129,18 @@ export function claudeToOpenAIResponse(chunk, state) { : 0; // Use OpenAI format keys for consistent logging in stream.js - // Issue #1426: Include cached tokens in prompt_tokens and input_tokens - const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens; + // Issue #1426: Include cache_read tokens in prompt_tokens so cached input + // is visible to downstream billing systems. + // Issue #2215: Exclude cache_creation_input_tokens from prompt_tokens — + // Anthropic's cache-creation pads short prompts up to a 1024-token + // minimum, so a 2-token "hi" can be reported as ~2008 prompt_tokens and + // inflate downstream billing ~250x. cache_creation is still exposed + // separately via prompt_tokens_details.cache_creation_tokens below. + const billableInputTokens = inputTokens + cacheReadTokens; state.usage = { - prompt_tokens: totalInputTokens, + prompt_tokens: billableInputTokens, completion_tokens: outputTokens, - input_tokens: totalInputTokens, + input_tokens: billableInputTokens, output_tokens: outputTokens, }; @@ -181,7 +187,8 @@ export function claudeToOpenAIResponse(chunk, state) { const cachedTokens = state.usage.cache_read_input_tokens || 0; const cacheCreationTokens = state.usage.cache_creation_input_tokens || 0; - // prompt_tokens = input_tokens (which now includes cache_read + cache_creation) + // prompt_tokens = input_tokens (input + cache_read, per #2215 — + // cache_creation is exposed separately in prompt_tokens_details below). // completion_tokens = output_tokens // total_tokens = prompt_tokens + completion_tokens const promptTokens = inputTokens; diff --git a/tests/unit/translator-resp-claude-to-openai.test.ts b/tests/unit/translator-resp-claude-to-openai.test.ts index 7d3b598dd4..f0e46e49f4 100644 --- a/tests/unit/translator-resp-claude-to-openai.test.ts +++ b/tests/unit/translator-resp-claude-to-openai.test.ts @@ -179,7 +179,7 @@ test("Claude stream: tool_use start reverses prefixed tool names and streams arg assert.equal(delta2[0].choices[0].delta.tool_calls[0].function.arguments, '"/tmp/a"}'); }); -test("Claude stream: message_delta maps stop reason and usage including cache tokens", () => { +test("Claude stream: message_delta maps stop reason and usage including cache tokens (#1426, #2215)", () => { const state = createState(); claudeToOpenAIResponse( { type: "message_start", message: { id: "msg1", model: "claude-3-7-sonnet" } }, @@ -201,13 +201,107 @@ test("Claude stream: message_delta maps stop reason and usage including cache to ); assert.equal(result[0].choices[0].finish_reason, "tool_calls"); - assert.equal(result[0].usage.prompt_tokens, 13); + // #2215: prompt_tokens = input + cache_read (excludes cache_creation overhead) + assert.equal(result[0].usage.prompt_tokens, 12); assert.equal(result[0].usage.completion_tokens, 4); - assert.equal(result[0].usage.total_tokens, 17); + assert.equal(result[0].usage.total_tokens, 16); + // cache_read continues to be visible in prompt_tokens_details (preserves #1426 intent) assert.equal(result[0].usage.prompt_tokens_details.cached_tokens, 2); + // cache_creation is exposed for auditing but does NOT inflate prompt_tokens assert.equal(result[0].usage.prompt_tokens_details.cache_creation_tokens, 1); }); +test("Claude stream: #2215 — short prompt with large cache_creation does not inflate prompt_tokens", () => { + const state = createState(); + claudeToOpenAIResponse( + { type: "message_start", message: { id: "msg1", model: "claude-sonnet-4-6" } }, + state + ); + + // Reproduces the scenario in the bug report: user sends "hi" with a long + // system prompt that triggers cache_control. Anthropic returns: + // input_tokens: 8 (just "hi") + // cache_creation_input_tokens: 2000 (system prompt being cached) + // cache_read_input_tokens: 0 (first turn, no cache hit yet) + // Before the fix: prompt_tokens = 2008 (8 + 0 + 2000). Now: prompt_tokens = 8. + const result = claudeToOpenAIResponse( + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 8, + output_tokens: 11, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 2000, + }, + }, + state + ); + + assert.equal(result[0].usage.prompt_tokens, 8); + assert.equal(result[0].usage.completion_tokens, 11); + assert.equal(result[0].usage.total_tokens, 19); + // cache_creation is auditable but not in prompt_tokens + assert.equal(result[0].usage.prompt_tokens_details.cache_creation_tokens, 2000); + // No cache_read so cached_tokens should not be set + assert.equal(result[0].usage.prompt_tokens_details.cached_tokens, undefined); +}); + +test("Claude stream: #2215 — cache_read alone is billable input (cache hit path)", () => { + const state = createState(); + claudeToOpenAIResponse( + { type: "message_start", message: { id: "msg1", model: "claude-sonnet-4-6" } }, + state + ); + + // Second turn: user sends another "hi". This time the system prompt is in + // cache (cache_read=2000), and only "hi" is fresh input (input=8). + // prompt_tokens should reflect everything the user effectively paid for: 8 + 2000 = 2008. + // cached_tokens reports how many were a hit. + const result = claudeToOpenAIResponse( + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 8, + output_tokens: 5, + cache_read_input_tokens: 2000, + cache_creation_input_tokens: 0, + }, + }, + state + ); + + assert.equal(result[0].usage.prompt_tokens, 2008); + assert.equal(result[0].usage.prompt_tokens_details.cached_tokens, 2000); + assert.equal(result[0].usage.prompt_tokens_details.cache_creation_tokens, undefined); +}); + +test("Claude stream: #2215 — no cache fields means no prompt_tokens_details", () => { + const state = createState(); + claudeToOpenAIResponse( + { type: "message_start", message: { id: "msg1", model: "claude-sonnet-4-6" } }, + state + ); + + const result = claudeToOpenAIResponse( + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 50, + output_tokens: 20, + }, + }, + state + ); + + assert.equal(result[0].usage.prompt_tokens, 50); + assert.equal(result[0].usage.completion_tokens, 20); + assert.equal(result[0].usage.total_tokens, 70); + assert.equal(result[0].usage.prompt_tokens_details, undefined); +}); + test("Claude stream: message_stop falls back to tool_calls when tool use already happened", () => { const state = createState(); claudeToOpenAIResponse(