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..0f7cb50c0514 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,82 @@ 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) + }) + + // 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, + }) + } + }) })