fix(translator): exclude cache_creation_input_tokens from prompt_tokens (#2215)

When OmniRoute translates Claude responses to the OpenAI format,
prompt_tokens was summing input + cache_read + cache_creation. Anthropic
pads short prompts up to a 1024-token minimum to create a cache, so:

  Request: {"messages":[{"role":"user","content":"hi"}]}
  Claude usage: input=8, cache_read=4, cache_creation=2000
  Dashboard "Total In": 8
  HTTP response prompt_tokens: 2008 (8 + 4 + 2000)

That 250x inflation broke downstream billing systems (Sub2API, NewAPI,
OneAPI) that trust prompt_tokens as the source of truth.

This change:
- prompt_tokens = input_tokens + cache_read_input_tokens (matches what
  the dashboard reports as "Total In", preserves issue #1426's intent
  that cache reads are billable input)
- cache_creation_tokens stays visible in
  prompt_tokens_details.cache_creation_tokens for auditing — it just no
  longer inflates the headline number

Reported by @downdawn with a complete root-cause trace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
diegosouzapw
2026-05-14 11:41:27 -03:00
parent 7ab68365ba
commit 5ce332d2ee
3 changed files with 110 additions and 8 deletions

View File

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

View File

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

View File

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