Repository navigation
fix(streaming): let a later usage event zero out stale cache counts (#40736) - #40845
muhammadwaqar12 wants to merge 3 commits into
Conversation
Greptile SummaryThe PR corrects streamed usage aggregation so a later prompt-side usage event can replace stale cache counters with explicit zeros while output-only events preserve prior cache usage.
Confidence Score: 5/5The PR appears safe to merge; the earlier provider-boundary and comment concerns are removed, and the parser-backed regression test exercises the intended behavior. No actionable new defects remain. The prior correctness thread was manually resolved, while the provider-specific shared-merger logic and excessive comments identified in the other previous threads are absent from the current code.
|
| Filename | Overview |
|---|---|
| litellm/litellm_core_utils/streaming_chunk_builder_utils.py | Updates generic streaming usage aggregation to replace cache counters when a later chunk reports prompt-side usage. |
| tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py | Adds end-to-end unit coverage through the Anthropic stream parser for authoritative cache counts and output-only usage events. |
Reviews (2): Last reviewed commit: "fix(streaming): let a later usage event ..." | Re-trigger Greptile
| # Anthropic's `message_delta` sometimes restates the prompt-side | ||
| # counts and sometimes zeroes the whole block out. A usage event | ||
| # whose prompt side is entirely zero carries no cache information | ||
| # at all, so its zeros must not clobber what `message_start` | ||
| # reported. An event that *does* report prompt-side input is | ||
| # authoritative for the whole block, explicit zeros included: | ||
| # Anthropic emits a positive cache-write in `message_start` and 0 | ||
| # in `message_delta` once the write is charged as a read, and the | ||
| # old `> 0` guard kept the stale positive, leaving | ||
| # `prompt_tokens - cache_read - cache_creation` negative | ||
| # downstream. | ||
| reports_prompt_side_usage = ( | ||
| usage_chunk_dict["prompt_tokens"] > 0 | ||
| or (usage_chunk_dict["cache_creation_input_tokens"] or 0) > 0 | ||
| or (usage_chunk_dict["cache_read_input_tokens"] or 0) > 0 | ||
| ) | ||
| # Field-presence semantics: the helper leaves these as None when | ||
| # the provider omits the field and carries the exact value | ||
| # (including 0) when it is present, so an omitted field always | ||
| # retains the running value. | ||
| if usage_chunk_dict["cache_creation_input_tokens"] is not None and ( | ||
| usage_chunk_dict["cache_creation_input_tokens"] > 0 or cache_creation_input_tokens is None | ||
| reports_prompt_side_usage or cache_creation_input_tokens is None | ||
| ): | ||
| cache_creation_input_tokens = usage_chunk_dict["cache_creation_input_tokens"] | ||
| if usage_chunk_dict["cache_read_input_tokens"] is not None and ( | ||
| usage_chunk_dict["cache_read_input_tokens"] > 0 or cache_read_input_tokens is None | ||
| reports_prompt_side_usage or cache_read_input_tokens is None | ||
| ): | ||
| cache_read_input_tokens = usage_chunk_dict["cache_read_input_tokens"] |
There was a problem hiding this comment.
Provider logic in shared merger This adds Anthropic-specific event handling outside
llms/, violating the repository requirement and must be moved before merge
Rule Used: What: Avoid writing provider-specific code outside of the llms/ directory. Why: This practice ensures better maintainability and reduces complexity over time. Good: ```python # Handle provider-specific logic within llms/vertex_ai/transformation.py ... (source)
| # Anthropic's `message_delta` sometimes restates the prompt-side | ||
| # counts and sometimes zeroes the whole block out. A usage event | ||
| # whose prompt side is entirely zero carries no cache information | ||
| # at all, so its zeros must not clobber what `message_start` | ||
| # reported. An event that *does* report prompt-side input is | ||
| # authoritative for the whole block, explicit zeros included: | ||
| # Anthropic emits a positive cache-write in `message_start` and 0 | ||
| # in `message_delta` once the write is charged as a read, and the | ||
| # old `> 0` guard kept the stale positive, leaving | ||
| # `prompt_tokens - cache_read - cache_creation` negative | ||
| # downstream. | ||
| reports_prompt_side_usage = ( | ||
| usage_chunk_dict["prompt_tokens"] > 0 | ||
| or (usage_chunk_dict["cache_creation_input_tokens"] or 0) > 0 | ||
| or (usage_chunk_dict["cache_read_input_tokens"] or 0) > 0 | ||
| ) | ||
| # Field-presence semantics: the helper leaves these as None when | ||
| # the provider omits the field and carries the exact value | ||
| # (including 0) when it is present, so an omitted field always | ||
| # retains the running value. |
There was a problem hiding this comment.
Comments exceed repository guidance These narratives repeat implementation history; repository rules require only necessary, concise source comments and must be satisfied before merging
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
fc2154e to
73ae767
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
73ae767 to
80f5c38
Compare
…erriAI#40736) The usage merger only replaced cache_creation_input_tokens and cache_read_input_tokens with a positive value, so when Anthropic's message_delta restated the cache block with a 0 write, the 58k write from message_start survived next to the 58k read. prompt_tokens minus both cache counts then went negative and the write was billed twice A usage event that reports any prompt-side count is now authoritative for both cache fields, zeros included. An event with no prompt-side counts, such as an output-only message_delta, still leaves the earlier values alone
80f5c38 to
66b46f8
Compare
|
@greptileai the new test runs raw Anthropic SSE events through the real parser, and the comments and provider logic are gone. Please re-review |
…cached input in the cache count merger test
|
Thanks for the fix. It landed on main through #42330 with your commit and authorship preserved, so closing this one. |
Superseded by #42330, the same commits on a
litellm_branch so the CircleCI suites can run; review and merge happen thereTLDR
Problem this solves:
How it solves it:
User Flow
Before: a developer whose app streams a cached Claude prompt through the proxy gets a usage block that still counts a cache write the provider took back, so the app's derived uncached input goes negative
POST https://litellm-domain/v1/chat/completionswith"stream": true,"stream_options": {"include_usage": true}, and a system block of about 58k tokens marked"cache_control": {"type": "ephemeral"}, routed to Claude Opus on Bedrockusagechunk anddata: [DONE]prompt_tokens: 58354,cache_read_input_tokens: 58352, andcache_creation_input_tokens: 58352, even though the provider's final count for this request said the cache was read, not written (thecache_creation_token_detailsin the same block already reads0and0)prompt_tokens - cache_read_input_tokens - cache_creation_input_tokens, stores-58350, and their usage dashboard shows negative input tokens for that userAfter: the same stream's usage block keeps the provider's final counts, so the derived uncached input is
2POST https://litellm-domain/v1/chat/completionswith"stream": true,"stream_options": {"include_usage": true}, and a system block of about 58k tokens marked"cache_control": {"type": "ephemeral"}, routed to Claude Opus on Bedrockusagechunk anddata: [DONE]prompt_tokens: 58354,cache_read_input_tokens: 58352, andcache_creation_input_tokens: 0, matching the provider's final count58354 - 58352 - 0 = 2and the dashboard shows 2 uncached input tokens for that userRelevant issues
Fixes #40736
Affected release
Linear ticket
Resolves LIT-7585
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Both legs ran a LiteLLM proxy booted with
--num_workers 2(two server processes behind one random port) in front of Claude Opus 5 on Bedrock through the Invoke route inus-east-1, real spend. Each of the three unified endpoints got the same streamed request twice with a fresh 800-clause system block markedcache_control(first request: cache write expected, second request: cache read expected). What is shown is the usage the client receives, plus, for chat, the uncached input the reporter's app derives asprompt_tokens - cache_read_input_tokens - cache_creation_input_tokensConfig,
lit7585_config.yaml:Boot:
python litellm/proxy/proxy_cli.py --config lit7585_config.yaml --port $PORT --num_workers 2Filler, unique per run so the first request is always a cache write:
FILLER=$(python3 -c "print(' '.join(f'clause {i} of the reference manual for run $RUN_ID states that widget {i % 97} must be inspected before shipment' for i in range(800)))")Request bodies (
body.jsonbelow is one of these, sent twice unchanged):{"model": "bedrock-opus", "stream": true, "stream_options": {"include_usage": true}, "max_tokens": 40, "messages": [{"role": "system", "content": [{"type": "text", "text": "$FILLER", "cache_control": {"type": "ephemeral"}}]}, {"role": "user", "content": "Reply with the single word OK."}]}{"model": "bedrock-opus", "stream": true, "max_output_tokens": 40, "input": [{"role": "system", "content": [{"type": "input_text", "text": "$FILLER", "cache_control": {"type": "ephemeral"}}]}, {"role": "user", "content": "Reply with the single word OK."}]}{"model": "bedrock-opus", "stream": true, "max_tokens": 40, "system": [{"type": "text", "text": "$FILLER", "cache_control": {"type": "ephemeral"}}], "messages": [{"role": "user", "content": "Reply with the single word OK."}]}The output shown for chat is the final
usageSSE chunk, for responses theresponse.completedevent'susage, and for messages theusageof themessage_startandmessage_deltaeventsThe reporter's symptom needs one stream whose
message_startreports a cache write and whosemessage_deltathen reports a cache read withcache_creation_input_tokens: 0. That sequence never occurred live during this QA: 20 Bedrock Invoke streams (12 plain, 8 started 400 ms apart on one identical cached prompt) and 8 Anthropic-direct streams sent straight to the providers all reported the same cache counts in both events, and so did the 18 proxy streams of the two legs. So the legs below show that the plain write and read shapes read the same before and after on every endpoint, and the reporter's exact event sequence is covered by the regression tests named under CaveatsBefore (3597d61)
/v1/chat/completions
curl -sS -N http://localhost:59448/v1/chat/completions -H 'Authorization: Bearer sk-lit7585-local' -H 'Content-Type: application/json' -d @body.json{"completion_tokens": 1, "prompt_tokens": 35217, "total_tokens": 35218, "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 1}, "prompt_tokens_details": {"cached_tokens": 0, "text_tokens": 14, "cache_write_tokens": 35203, "cache_creation_tokens": 35203, "cache_creation_token_details": {"ephemeral_5m_input_tokens": 35203, "ephemeral_1h_input_tokens": 0}}, "cost": 0.24212512500000002, "cache_creation_input_tokens": 35203, "cache_read_input_tokens": 0}{"completion_tokens": 1, "prompt_tokens": 35217, "total_tokens": 35218, "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 1}, "prompt_tokens_details": {"cached_tokens": 35203, "text_tokens": 14, "cache_write_tokens": 0, "cache_creation_tokens": 0, "cache_creation_token_details": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}}, "cost": 0.01946615, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 35203}/v1/responses
curl -sS -N http://localhost:59448/v1/responses -H 'Authorization: Bearer sk-lit7585-local' -H 'Content-Type: application/json' -d @body.json{"input_tokens": 35217, "input_tokens_details": {"cached_tokens": 0, "text_tokens": 14, "cache_write_tokens": 35203}, "output_tokens": 4, "output_tokens_details": {"reasoning_tokens": 0, "text_tokens": 4}, "total_tokens": 35221, "cost": 0.242207625}{"input_tokens": 35217, "input_tokens_details": {"cached_tokens": 35203, "text_tokens": 14, "cache_write_tokens": 0}, "output_tokens": 4, "output_tokens_details": {"reasoning_tokens": 0, "text_tokens": 4}, "total_tokens": 35221, "cost": 0.01954865}/v1/messages
curl -sS -N http://localhost:59448/v1/messages -H 'Authorization: Bearer sk-lit7585-local' -H 'Content-Type: application/json' -d @body.jsonAfter (9a4b19a)
/v1/chat/completions
curl -sS -N http://localhost:26333/v1/chat/completions -H 'Authorization: Bearer sk-lit7585-local' -H 'Content-Type: application/json' -d @body.json{"completion_tokens": 1, "prompt_tokens": 35217, "total_tokens": 35218, "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 1}, "prompt_tokens_details": {"cached_tokens": 0, "text_tokens": 14, "cache_write_tokens": 35203, "cache_creation_tokens": 35203, "cache_creation_token_details": {"ephemeral_5m_input_tokens": 35203, "ephemeral_1h_input_tokens": 0}}, "cost": 0.24212512500000002, "cache_creation_input_tokens": 35203, "cache_read_input_tokens": 0}{"completion_tokens": 1, "prompt_tokens": 35217, "total_tokens": 35218, "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 1}, "prompt_tokens_details": {"cached_tokens": 35203, "text_tokens": 14, "cache_write_tokens": 0, "cache_creation_tokens": 0, "cache_creation_token_details": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}}, "cost": 0.01946615, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 35203}/v1/responses
curl -sS -N http://localhost:26333/v1/responses -H 'Authorization: Bearer sk-lit7585-local' -H 'Content-Type: application/json' -d @body.json{"input_tokens": 35217, "input_tokens_details": {"cached_tokens": 0, "text_tokens": 14, "cache_write_tokens": 35203}, "output_tokens": 1, "output_tokens_details": {"reasoning_tokens": 0, "text_tokens": 1}, "total_tokens": 35218, "cost": 0.24212512500000002}{"input_tokens": 35217, "input_tokens_details": {"cached_tokens": 35203, "text_tokens": 14, "cache_write_tokens": 0}, "output_tokens": 1, "output_tokens_details": {"reasoning_tokens": 0, "text_tokens": 1}, "total_tokens": 35218, "cost": 0.01946615}/v1/messages
curl -sS -N http://localhost:26333/v1/messages -H 'Authorization: Bearer sk-lit7585-local' -H 'Content-Type: application/json' -d @body.jsonObserved alongside, none caused or changed by this PR:
inputcounts include cached tokens; messagesinput_tokensexcludes themcache_write_tokensonly, nocache_creation_input_tokenskeyoutput_tokensinmessage_startalready equals the final countType
Bug Fix
Test
Caveats (if any)
Medium
tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py::test_anthropic_stream_usage_takes_cache_counts_from_last_event_that_reports_them, three cases through the real Anthropic stream parser and the merger, all failing on the merge base and passing hereLow
message_deltawithout cache fields inherits themessage_startcounts; thedelta_reports_input_and_output_onlycase pins that, so the earlier worry that such an event would clear both counts does not apply at this headtests/local_testingandtests/llm_translationdo not run on a fork head, sotests/local_testing/test_anthropic_prompt_caching.pywas run locally at this tip against the real Anthropic API: 7 passed (the streaming case included), 2 skipped, 4 failedtest_anthropic_vertex_ai_prompt_cachingnames a Vertex model that is no longer in the cost map, andtest_litellm_anthropic_prompt_caching_systemandtest_litellm_anthropic_prompt_caching_toolsassert a mockedpostcall without thelogging_objkwarg the handler now passes; none of them reach the usage mergerFinal Attestation