Skip to content

fix(gateway): bill from the usage the chain normalized, not the wire it received - #466

Open
Menci wants to merge 1 commit into
mainfrom
fix/meter-usage-above-normalization
Open

fix(gateway): bill from the usage the chain normalized, not the wire it received#466
Menci wants to merge 1 commit into
mainfrom
fix/meter-usage-above-normalization

Conversation

@Menci

@Menci Menci commented Aug 10, 2026

Copy link
Copy Markdown
Owner

The report

A Chat Completions upstream that reports its cache buckets alongside prompt_tokens instead of inside it tears the stream down mid-response:

RangeError: cache token counts exceed inclusive input tokens: 50 - 5450 - 0
    at splitInclusiveInputTokens (packages/protocols/src/common/usage.ts:99)
    at billableUsageFromChatCompletionsUsage (…/chat-completions/usage.ts:13)
    at …/shared/provider-stream-result.ts:41
    at async …/interceptors/normalize-exclusive-cached-tokens.ts:87

usage-exclusive-cached-tokens was 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. withExclusiveCachedTokensNormalized folded the cache buckets back into prompt_tokens for 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 at normalize-exclusive-cached-tokens.ts:87 is 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_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 (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:

  • chat-completions — outermost.
  • responses — below the compact and server-tool shims, so each turn the ReAct loop composes is metered on its own. Metering above the shim would instead read its stitched stream, whose synthesized usage drops cache_write_tokens and carries turn 1's service_tier on a multi-turn sum.
  • messages — below the Claude Code probe, so a turn that never dialed stays unbilled instead of recording a zero-token row, and below the web-search shim, which also keeps the reader's message_start / message_delta merge scoped to one turn (that merge is last-write-wins, not additive).

The contribution is merge-shaped — only billableUsage is written — so a shim's latest-turn modelIdentity / performance survive, 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.

Tests

billable-usage-meter_test.ts covers 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 from provider-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_tokens witnessing the exclusive convention on its own, and a DeepSeek cache-hit count. Before this change the first two crashed and the third silently reported input: 100, cacheRead: 0 where input: 40, cacheRead: 60 was owed.

Note for operators

Historical usage rows for vendor-dialect upstreams undercount cache reads and overcount input. Repricing cannot correct them — the recorded quantities, not the rates, are what is wrong.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant