Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/console/app/src/routes/zen/util/provider/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
80 changes: 79 additions & 1 deletion packages/console/app/test/providerUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,90 @@ describe("provider usage extraction", () => {
)

expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({
inputTokens: 6,
inputTokens: 3,
outputTokens: 2,
reasoningTokens: undefined,
cacheReadTokens: 4,
cacheWrite5mTokens: 3,
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,
})
}
})
})
Loading