mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user