fix(gateway): bill from the usage the chain normalized, not the wire it received - #466
Open
Menci wants to merge 1 commit into
Open
fix(gateway): bill from the usage the chain normalized, not the wire it received#466Menci wants to merge 1 commit into
Menci wants to merge 1 commit into
Conversation
…it received
A Chat Completions upstream that reports its cache buckets alongside
`prompt_tokens` instead of inside it tore the stream down mid-response with
`RangeError: cache token counts exceed inclusive input tokens: 50 - 5450 - 0`,
even with `usage-exclusive-cached-tokens` enabled for it.
The flag was doing its job; nothing was reading the result. Billable usage was
read at the terminal that calls the provider — the innermost point of the
interceptor chain — so every inbound rewrite above it was invisible to the
figure. The exclusive-cache fold repaired the wire the client sees and left the
billing read looking at the upstream's raw counts, where the split underflows.
The interceptor's own test passed throughout, because its fake `run()` had no
billing read in it.
The same blindness hid a second, silent defect: the vendor normalizers remap
DeepSeek's `prompt_cache_hit_tokens` and Kimi's flat `cached_tokens` into
OpenAI's names on that same inbound path, so every vendor-dialect cache hit was
billed as uncached input at the full input rate.
Metering is now an interceptor, `meteringBillableUsage`, positioned above every
entry that rewrites usage and below every entry that composes turns or answers
without dialing an upstream:
- chat-completions: outermost.
- responses: below the compact and server-tool shims, so each turn the ReAct
loop composes is metered on its own rather than read off the lossy stitched
stream the shim synthesizes.
- messages: below the Claude Code probe, so a turn that never dialed stays
unbilled, and below the web-search shim, which also keeps the reader's
`message_start`/`message_delta` merge scoped to one turn.
Its contribution is merge-shaped — only `billableUsage` — so a shim's
latest-turn attribution survives and a native Responses compaction keeps the
figure its body stated.
`providerStreamResultToExecuteResult` loses its reader parameter and now stamps
only model identity and first-output-token timing. That is what keeps the class
from recurring: the seam below the normalizers no longer has the means to
produce a billing figure at all.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The report
A Chat Completions upstream that reports its cache buckets alongside
prompt_tokensinstead of inside it tears the stream down mid-response:usage-exclusive-cached-tokenswas enabled for that upstream, and made no difference.Root cause
The flag was doing its job; nothing was reading the result.
Billable usage was read at the terminal that calls the provider — the innermost point of the interceptor chain. Every inbound rewrite sits above that point, so no rewrite could ever reach the figure.
withExclusiveCachedTokensNormalizedfolded the cache buckets back intoprompt_tokensfor the wire the client sees, while the billing read went on looking at the upstream's raw counts, where the split underflows. The stack above shows exactly this: the frame atnormalize-exclusive-cached-tokens.ts:87is that interceptor pulling from the inner generator that throws.The interceptor's own test passed throughout — its fake
run()returns a stream with no billing read in it.The same blindness hid a second defect, silent rather than fatal: the vendor normalizers remap DeepSeek's
prompt_cache_hit_tokensand Kimi's flatcached_tokensinto OpenAI's names on that same inbound path, so every vendor-dialect cache hit was billed as uncached input at the full input rate (cacheRead: 0).The fix
Metering becomes an interceptor,
meteringBillableUsage, positioned above every entry that rewrites usage and below every entry that composes turns or answers without dialing an upstream:cache_write_tokensand carries turn 1'sservice_tieron a multi-turn sum.message_start/message_deltamerge scoped to one turn (that merge is last-write-wins, not additive).The contribution is merge-shaped — only
billableUsageis written — so a shim's latest-turnmodelIdentity/performancesurvive, and a native Responses compaction keeps the figure its body stated.providerStreamResultToExecuteResultloses its reader parameter and now stamps only model identity and first-output-token timing. That is what keeps the class from recurring: the seam below the normalizers no longer has the means to produce a billing figure at all.Tests
billable-usage-meter_test.tscovers the meter itself: last real reading wins, stand-down on a translated target, no clobbering of an inner figure, settlement on cancellation, and no settlement before the terminal usage frame (that last one moved over fromprovider-stream-result_test.ts, where it had been asserting the old seam).Three end-to-end regressions run through the real chains — flag-declared exclusive counts,
total_tokenswitnessing the exclusive convention on its own, and a DeepSeek cache-hit count. Before this change the first two crashed and the third silently reportedinput: 100, cacheRead: 0whereinput: 40, cacheRead: 60was owed.Note for operators
Historical
usagerows for vendor-dialect upstreams undercount cache reads and overcount input. Repricing cannot correct them — the recorded quantities, not the rates, are what is wrong.