From 2aa258b15b0d860862be2d3ce39fc8ac4e6da539 Mon Sep 17 00:00:00 2001 From: Mark Yan Date: Sat, 22 Aug 2026 20:12:10 +0000 Subject: [PATCH 1/2] fix(zen): OpenAI cache-write tokens are part of input_tokens, not extra normalizeUsage subtracted cached_tokens from input_tokens but left cache_write_tokens in. Both are subsets of input_tokens -- OpenAI's own sample is 2600 = 2000 read + 400 written + 200 neither -- so the written tokens stayed in inputTokens while also being reported as cacheWrite5mTokens. Two effects in handler.ts: the long-context threshold sums all four buckets, so it overshot the real prompt by exactly cache_write_tokens and tripped early; and inputCost priced those tokens at the input rate while cacheWrite5mCost priced them again at the cache-write rate. session.ts already subtracts both on the local side. Anthropic's helper is unchanged and must stay that way: its input_tokens genuinely excludes both cache buckets. --- .../src/routes/zen/util/provider/openai.ts | 7 +++++- .../console/app/test/providerUsage.test.ts | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index cebc78a127a3..e5767b787bd3 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -51,7 +51,12 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined return { - inputTokens: inputTokens - (cacheReadTokens ?? 0), + // `input_tokens` is the whole prompt: cached_tokens and cache_write_tokens are + // both subsets of it, not addends on top of it (OpenAI's own sample: 2600 = + // 2000 read + 400 written + 200 neither). Subtract both so the four buckets + // downstream are disjoint -- handler.ts sums them for the long-context + // threshold and prices each one separately. + inputTokens: inputTokens - (cacheReadTokens ?? 0) - (cacheWriteTokens ?? 0), outputTokens, reasoningTokens, cacheReadTokens, diff --git a/packages/console/app/test/providerUsage.test.ts b/packages/console/app/test/providerUsage.test.ts index 7be3aacffddb..8b326127b340 100644 --- a/packages/console/app/test/providerUsage.test.ts +++ b/packages/console/app/test/providerUsage.test.ts @@ -73,7 +73,7 @@ describe("provider usage extraction", () => { ) expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({ - inputTokens: 6, + inputTokens: 3, outputTokens: 2, reasoningTokens: undefined, cacheReadTokens: 4, @@ -81,4 +81,26 @@ describe("provider usage extraction", () => { cacheWrite1hTokens: undefined, }) }) + + test("OpenAI cache buckets are subsets of input_tokens, not addends", () => { + // The sample from OpenAI's prompt-caching guide: 2600 input tokens made up of + // 2000 read from cache, 400 newly written, and 200 that were neither. + const normalized = providers.openai.normalizeUsage({ + input_tokens: 2600, + input_tokens_details: { cached_tokens: 2000, cache_write_tokens: 400 }, + output_tokens: 10, + }) + + expect(normalized.inputTokens).toBe(200) + + // The four buckets must partition the prompt exactly -- handler.ts adds them + // up to pick the long-context tier, and double-counting the written tokens + // trips that threshold early. + const promptTokens = + normalized.inputTokens + + (normalized.cacheReadTokens ?? 0) + + (normalized.cacheWrite5mTokens ?? 0) + + (normalized.cacheWrite1hTokens ?? 0) + expect(promptTokens).toBe(2600) + }) }) From 73c236376c555df172595733c06e8de4094d94ac Mon Sep 17 00:00:00 2001 From: Mark Yan Date: Mon, 24 Aug 2026 05:38:29 +0000 Subject: [PATCH 2/2] test(zen): extend the four-bucket partition guard to the Anthropic and Google helpers --- .../console/app/test/providerUsage.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/console/app/test/providerUsage.test.ts b/packages/console/app/test/providerUsage.test.ts index 8b326127b340..0f7cb50c0514 100644 --- a/packages/console/app/test/providerUsage.test.ts +++ b/packages/console/app/test/providerUsage.test.ts @@ -103,4 +103,60 @@ describe("provider usage extraction", () => { (normalized.cacheWrite1hTokens ?? 0) expect(promptTokens).toBe(2600) }) + + // The invariant above is not OpenAI-specific: handler.ts adds the same four + // buckets for every provider to pick the long-context tier. The wire shapes + // disagree about whether the cache buckets live inside the prompt count -- + // OpenAI's input_tokens includes them, Anthropic's excludes them, Google has + // no cache-write bucket at all -- so the guard belongs on the normalized + // side, where all three must agree. Same 2,600-token prompt three ways. + test("every helper partitions the prompt into the same four buckets", () => { + const cases = [ + { + name: "openai", + expectedInput: 200, + normalized: providers.openai.normalizeUsage({ + input_tokens: 2600, + input_tokens_details: { cached_tokens: 2000, cache_write_tokens: 400 }, + output_tokens: 10, + }), + }, + { + name: "anthropic", + expectedInput: 200, + normalized: providers.anthropic.normalizeUsage({ + input_tokens: 200, + cache_read_input_tokens: 2000, + cache_creation: { ephemeral_5m_input_tokens: 400 }, + output_tokens: 10, + }), + }, + { + name: "google", + expectedInput: 600, + normalized: providers.google.normalizeUsage( + providers.google.extractUsage({ + usageMetadata: { + promptTokenCount: 2600, + cachedContentTokenCount: 2000, + candidatesTokenCount: 10, + }, + }), + ), + }, + ] + + for (const { name, expectedInput, normalized } of cases) { + const promptTokens = + normalized.inputTokens + + (normalized.cacheReadTokens ?? 0) + + (normalized.cacheWrite5mTokens ?? 0) + + (normalized.cacheWrite1hTokens ?? 0) + expect({ name, inputTokens: normalized.inputTokens, promptTokens }).toEqual({ + name, + inputTokens: expectedInput, + promptTokens: 2600, + }) + } + }) })