Skip to content

fix(zen): OpenAI cache-write tokens are part of input_tokens, not extra - #44229

Open
xyzs996 wants to merge 1 commit into
anomalyco:devfrom
xyzs996:zen-openai-cache-write-subset
Open

fix(zen): OpenAI cache-write tokens are part of input_tokens, not extra#44229
xyzs996 wants to merge 1 commit into
anomalyco:devfrom
xyzs996:zen-openai-cache-write-subset

Conversation

@xyzs996

@xyzs996 xyzs996 commented Aug 22, 2026

Copy link
Copy Markdown

Issue for this PR

Refs #42910 (comment: #42910 (comment)). Independent of #44223, which fixes the local-side experimentalOver200K fallback.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

normalizeUsage in the Zen OpenAI helper subtracts cached_tokens from input_tokens but leaves cache_write_tokens in. Both are subsets of input_tokens, per OpenAI's own sample in the prompt-caching guide:

usage.input_tokens = 2600
usage.input_tokens_details.cached_tokens = 2000
usage.input_tokens_details.cache_write_tokens = 400

In this example, 2,000 tokens were read from the cache and 400 additional tokens were written.
The remaining 200 input tokens were neither read nor written.

2,000 + 400 + 200 = 2,600 — https://developers.openai.com/api/docs/guides/prompt-caching

So the written tokens stayed inside inputTokens and were reported as cacheWrite5mTokens, and handler.ts uses those buckets two ways:

1. Long-context threshold trips early. calculateCost sums all four buckets to pick the tier. Substituting, that sum is (input_tokens − cached) + cached + written = input_tokens + written — over the real prompt by exactly cache_write_tokens, every request. On a well-cached turn written ≈ 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: inputTokens and cacheWrite5mTokens differing 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 * inputTokens charges the written tokens at the input rate, then cacheWrite5mCost = modelCost.cacheWrite5m * cacheWrite5mTokens charges 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." On gpt-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.ts already 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_tokens genuinely excludes both cache buckets, so anthropic.ts is 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 in openai.ts rather than in calculateCost.

How did you verify your code works?

packages/console/app/test/providerUsage.test.ts:

  • The existing "parses OpenAI stream cache write usage" case (input_tokens: 10, cached_tokens: 4, cache_write_tokens: 3) expected inputTokens: 6; it now expects 3. That existing expectation was pinning the bug, so it changes with the fix rather than being worked around.
  • New case using OpenAI's documented sample verbatim: 2600 / 2000 / 400inputTokens: 200, plus an assertion that the four buckets sum back to exactly 2600, which is the invariant handler.ts relies on for the tier.

Screenshots / recordings

N/A

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

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.
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference, author can ignore or act on any point.

The accounting model (four disjoint buckets partitioning input_tokens) is internally consistent, and the new partition test is exactly the right regression guard (packages/console/app/test/providerUsage.test.ts:84-105) — summing the buckets back to 2600 pins the invariant, not just the arithmetic. Two things to tighten:

  1. Verify the cited source before merge. The comment at packages/console/app/src/routes/zen/util/provider/openai.ts:54-58 attributes the 2600 = 2000-read + 400-written + 200-neither breakdown to "OpenAI's own sample", and the code reads input_tokens_details.cache_write_tokens. To my knowledge OpenAI's published usage schema documents only cached_tokens as a subset of input tokens; a first-class cache_write_tokens field is characteristic of Anthropic-style accounting. If the field actually arrives via a zen-side gateway/upstream rather than OpenAI proper, please point the comment (and PR body) at the real producer — otherwise future maintainers may "fix" this back based on OpenAI's official docs. The ?? undefined fallback means unpopulated fields stay safe either way.

  2. Extend the partition invariant to the other helpers. handler.ts sums the four buckets for long-context tiering regardless of provider; the same sum-to-prompt assertion added here for OpenAI would be cheap to replicate for the Anthropic/other helpers in providerUsage.test.ts, whose input_tokens semantics differ (Anthropic excludes cache tokens from input_tokens natively), guarding against someone "harmonizing" the wrong direction later.

Otherwise clean, minimal, and well-tested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants