fix(zen): OpenAI cache-write tokens are part of input_tokens, not extra - #44229
fix(zen): OpenAI cache-write tokens are part of input_tokens, not extra#44229xyzs996 wants to merge 2 commits into
Conversation
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.
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
The accounting model (four disjoint buckets partitioning
Otherwise clean, minimal, and well-tested. |
|
Thanks — both points were worth raising. Taking them in order. 1. The field is OpenAI's, and the sample is from their guide. I re-checked https://developers.openai.com/api/docs/guides/prompt-caching before answering. "input_tokens": 2600,
"input_tokens_details": {
"cached_tokens": 2000,
"cache_write_tokens": 400
}with the accompanying text stating that 2,000 tokens were read from cache, 400 were newly written, and the remaining 200 were "neither read nor written". The billing side is equally explicit: "Tokens written to the cache are billed at 1.25× the uncached input token rate.", "The 1.25× cache-write rate is the total rate for written tokens.", and "It is not an additional charge on top of another full input-token charge." So the comment's attribution is accurate as written, and no zen-side gateway is inventing the field. Your underlying worry is the right one though — someone reading an older copy of the usage schema would see only 2. Extended the invariant to the other helpers — 73c2363. Rather than duplicating the assertion per provider, the new case runs the same 2,600-token prompt through all three helpers in each one's own wire shape and asserts both the normalized
All three normalize to a partition summing to 2600. That is exactly the "harmonizing in the wrong direction" guard you described: the wire formats disagree about whether the cache buckets live inside the prompt count, and the assertion pins that they must agree after normalization, which is the only place 3. Unrelated heads-up while I was in that file. Two pre-existing cases in Both assert |
|
Thanks — both points are answerable, one with a citation and one with a commit. 1. The field is OpenAI's own, documented, with that exact exampleNot a zen-side gateway invention. From OpenAI's prompt-caching guide (https://developers.openai.com/api/docs/guides/prompt-caching):
and the worked example the code comment is pointing at: "input_tokens": 2600,
"input_tokens_details": { "cached_tokens": 2000, "cache_write_tokens": 400 }
So You're right that this reads as Anthropic-shaped accounting; the difference is that Anthropic puts its cache buckets outside The same page also independently supports the double-billing half of the fix:
No comment or PR-body change needed, but if you'd prefer the doc URL inlined at 2. Already extended — 73c2363Pushed after your review:
Google lands at 600 legitimately — One helper I deliberately left out: |
Issue for this PR
Refs #42910 (comment: #42910 (comment)). Independent of #44223, which fixes the local-side
experimentalOver200Kfallback.Type of change
What does this PR do?
normalizeUsagein the Zen OpenAI helper subtractscached_tokensfrominput_tokensbut leavescache_write_tokensin. Both are subsets ofinput_tokens, per OpenAI's own sample in the prompt-caching guide:2,000 + 400 + 200 = 2,600 — https://developers.openai.com/api/docs/guides/prompt-caching
So the written tokens stayed inside
inputTokensand were reported ascacheWrite5mTokens, andhandler.tsuses those buckets two ways:1. Long-context threshold trips early.
calculateCostsums all four buckets to pick the tier. Substituting, that sum is(input_tokens − cached) + cached + written=input_tokens + written— over the real prompt by exactlycache_write_tokens, every request. On a well-cached turnwritten ≈ inputTokens, so the sum runs near 2× the prompt and the gate trips at roughly half the real context. This matches what @Zaczero measured on a 302-request Luna burst in #42910:inputTokensandcacheWrite5mTokensdiffering by 3 on almost every row (that 3 is the "neither read nor written" remainder), 94 requests in the 200–272k band paying the high rate, +$2.53 on one afternoon.2. The same tokens are billed twice.
inputCost = modelCost.input * inputTokenscharges the written tokens at the input rate, thencacheWrite5mCost = modelCost.cacheWrite5m * cacheWrite5mTokenscharges them again. OpenAI rules that out explicitly: "Tokens written to the cache are billed at 1.25× the uncached input token rate." … "It is not an additional charge on top of another full input-token charge." Ongpt-5.6-luna($0.20/M input) a written token should cost $0.25/M and is billed $0.45/M — 1.8×, on every cached request, regardless of any threshold.One line fixes both.
session.tsalready does this correctly on the local side (it subtracts both, with a comment explaining why), so this brings Zen in line with it.Anthropic is deliberately untouched and must stay that way — Anthropic's
input_tokensgenuinely excludes both cache buckets, soanthropic.tsis right to pass it through and the four-bucket sum is already correct there. Google has no cache-write bucket. That asymmetry is why the fix belongs inopenai.tsrather than incalculateCost.How did you verify your code works?
packages/console/app/test/providerUsage.test.ts:input_tokens: 10,cached_tokens: 4,cache_write_tokens: 3) expectedinputTokens: 6; it now expects3. That existing expectation was pinning the bug, so it changes with the fix rather than being worked around.2600 / 2000 / 400→inputTokens: 200, plus an assertion that the four buckets sum back to exactly2600, which is the invarianthandler.tsrelies on for the tier.Screenshots / recordings
N/A
Checklist