Repository navigation
fix(gateway): stop subtracting cached input from Anthropic streaming usage - #436
Merged
Merged
Conversation
…usage Anthropic reports input_tokens, cache_read_input_tokens and cache_creation_input_tokens as three disjoint segments, so input_tokens already excludes cache reads. The streaming path subtracted the cache-read amount from it anyway, clamping the uncached segment to zero and billing those tokens as cache hits only. The criterion now lives in one function, gateway::uncached_input: the OpenAI shape (openai_chat / responses), whose prompt_tokens does include the hit, subtracts it; anthropic does not. UsageCapture::finish uses it for same-protocol streaming, and the three record_usage sites in sse::anthropic_sse_to_openai use it for the cross-protocol path. That converter also backs anthropic_sse_to_responses, so responses clients inherit the fix. The non-streaming path already expressed this rule through parse_usage's per-protocol match and is unchanged except that it now agrees with the streaming path on the same usage. Four assertions encoded the defect as the expected value and are corrected; one new test pins the criterion to the protocol rather than to the operation, so unifying the two paths back onto the subtraction would fail. Closes #435
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.
Summary
Anthropic reports
input_tokens,cache_read_input_tokensandcache_creation_input_tokensas three disjoint segments, soinput_tokensalready excludes cache reads. On the streaming path this repository subtracted the cache-read amount from it anyway: the uncached segment clamped to zero, and those tokens were billed as cache hits only.gateway.rs's own module header has said so since the cache-write work landed: listing where each protocol keeps its cache-read count, itsanthropicline reads, in the original Chinese, "input_tokensalready excludes the hit, do not subtract". The prose was right and the code below it was wrong.This is the streaming Anthropic undercount that #434 named in its own body as tracked separately and deliberately untouched. It is that follow-up.
Related Issue
Closes #435
The defect is four sites, not one
I checked before writing code, because patching only the obvious one would have left the client-facing path broken. All four have the same root cause; the same expression
(input - cached).max(0.0)was applied to every protocol.gateway.rs—UsageCapture::finishanthropicinput_tokensgateway.rs—UsageCapture::finishopenai_chat,responsesprompt_tokensdoes include the hitsse.rs—anthropic_sse_to_openai,message_deltabranchanthropicsse.rs—anthropic_sse_to_openai,message_stopbranchanthropicsse.rs—anthropic_sse_to_openai, end-of-stream fallbackanthropicsse::anthropic_sse_to_responsesis a wrapper aroundanthropic_sse_to_openai, so responses clients carried the same defect without appearing anywhere in the diff.The three
record_usagesites in the openai / responses upstream converters are correct and are unchanged: thereinput_tokensgenuinely includes cache reads, and subtracting is what stops the same tokens from being charged once atinput_per_mand again atcache_hit_input_per_m.Changes
gateway::uncached_input(protocol, input, cached).anthropicreturnsinputunchanged; the OpenAI shape subtracts with.max(0.0), and that clamp guards the OpenAI underflow (upstream reportscached > prompt), not the protocol difference.UsageCapture::finishcalls it withself.protocolinstead of recomputing the expression.record_usagesites call a one-line local wrapper,sse::anthropic_uncached_input, which delegates to the same function — so the rule stays single-sourced while each call site stays readable.gateway.rs, two insse.rs. The load-bearing one asserted(input, cached) = (80, 70)yieldsinput == 10.0foranthropic; the correct value is80.0. Theopenaihalf of that same test (prompt_tokens 100, cached 90 → 10.0) was right and still is.streaming_uncached_input_follows_the_protocol_not_the_operation: the same numbers (input 100, cached 90, output 50) through both protocols, asserting(10, 90, 50)foropenai_chatand(100, 90, 50)foranthropic. Pinning only the Anthropic half would let a future "unification" back onto the subtraction pass; this pairing is what makes that fail.Billing impact, stated precisely: existing ledger rows are not rewritten; the change alters settlement from this point on — the same traffic becomes more expensive. The worked example in #435, at
claude-sonnet-5.5catalogue prices ($2.00 input / $0.10 cache-read / $10.00 output per million):input=2,000, cache_read=60,000, output=1,000settles at $0.020000; streamed today it settles at $0.016000, because the uncached segment was clamped to zero — ~20% of that call, and the larger the cached share, the wider the gap.Tests
cargo test— 584 passed, 0 failed (baseline 583, plus the one new test).cargo fmt --check— clean.cargo clippy --all-targets -- -D warnings— clean (the CI invocation,.github/workflows/ci.yml:30).Both directions of the fix were checked by mutation, not just asserted:
both_usage_parsers_read_anthropic_cache_creation_tokens,streaming_uncached_input_follows_the_protocol_not_the_operation,usage_capture_parses_openai_and_anthropic, andsse::tests::anthropic_cache_read_spelling_disjoint.openai/responsesside, which must keep subtracting.Deliberate boundaries
record_usagesites are untouched on purpose. A blanket removal of the subtraction would have introduced the mirror-image defect: double-charging cached tokens.parse_usageexpresses the same rule through a per-protocolmatch— and its behaviour is unchanged. What changed is that streaming now agrees with it on the same usage; a test that previously pinned the two paths to different values ((0.0, 100.0)) now pins them to the same one ((100.0, 100.0)).Checklist
fix/)