fix(cost): match streamed Messages usage cost to the recorded spend - #35114
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Greptile SummaryUpdates streamed usage-cost calculation to align with recorded spend:
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| litellm/llms/anthropic/chat/transformation.py | Broadens Anthropic usage transformation input typing to accept mappings without changing its runtime normalization behavior. |
| litellm/proxy/common_request_processing.py | Rebuilds streamed Anthropic usage and prices it through request-specific logging configuration while restoring mutable logging state. |
| litellm/proxy/pass_through_endpoints/streaming_handler.py | Passes the active logging object into cost injection for pass-through streaming responses. |
| tests/test_litellm/proxy/test_common_request_processing.py | Adds focused regression tests covering cached usage normalization, custom deployment pricing, fallback behavior, and state restoration. |
Reviews (4): Last reviewed commit: "fix(cost): keep mid-stream pricing from ..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
6445568 to
0652aa4
Compare
0652aa4 to
63770e5
Compare
…logging obj Streamed `/v1/messages` `usage.cost` disagreed with the cost the logging callback recorded in three ways: `input_tokens` was read as the whole prompt total, but Anthropic reports it excluding cache tokens, so the non-cached input went unbilled on cache hits; the `cache_creation` 5m/1h split was dropped, billing 1h writes at the 5m rate; and costing by model name alone ignored the deployment's custom pricing, so a negotiated discount still streamed sticker price. Anthropic usage now goes through `AnthropicConfig.calculate_usage`, the same transformation the non-streaming path uses, and the chunk is priced through the call's logging object when there is one so it inherits `custom_pricing`, `custom_llm_provider`, `base_model` and `router_model_id`, falling back to `completion_cost` by model name. `calculate_usage` only reads its `usage_object`, so it now takes a `Mapping`. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2b04587 to
5d5dc45
Compare
…us bits Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The logging-object pricing applies to streamed /v1/chat/completions too, not just Anthropic message_delta, so a deployment with negotiated per-token prices now gets that price in the streamed usage.cost there as well. Nothing asserted that half. Adds the discounted and the sticker-fallback case for the OpenAI chunk shape, plus the branch where the pricer raises and the frame falls back to model-name pricing instead of breaking the stream.
…spend log Pricing a frame through the request's own logging object is what makes custom deployment pricing work, but _response_cost_calculator does not only return a number. It also stamps cost_breakdown onto the live logging object, and on a pricing failure it writes response_cost_failure_debug_information into model_call_details. On an ordinary proxy stream that is harmless, because the success handler recomputes cost_breakdown at end of stream and overwrites whatever the frames left behind. The pass-through handlers are the problem: they compute their final cost with a bare completion_cost call and never touch cost_breakdown again, so a breakdown derived from one mid-stream frame would survive to the end and land in the spend log's metadata. response_cost itself is unaffected either way, so this was a reporting surface bug rather than a billing one, but the spend row would have gone from null to a populated breakdown for a partial frame. Snapshot both writes and put them back once the cost is read, so pricing a frame stays a read as far as the rest of the request is concerned. The returned cost is unchanged, so nothing about the injected usage.cost moves.
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 655d107. Configure here.
TLDR
Problem this solves:
usage.costdisagreed with the logged spendHow it solves it:
User Flow
Before: a developer streaming Anthropic Messages through the gateway gets a per-request cost in the stream that does not match the spend the gateway records, so their in-app cost meter and their invoice disagree
include_cost_in_streaming_usage: True, and restarts the proxy"stream": trueand a large cached system promptmessage_deltaframe reports"input_tokens": 12, "cache_read_input_tokens": 3467alongside"cost": 0.0011001"spend": 0.0003787, so the stream claimed 2.9x what they are billed0.013155while the spend row reads0.007016, off by 1.875x0.0011001against a recorded0.0011361, short by exactly the 12 uncached input tokensAfter: the same requests report the same cost in the stream as the gateway records, so the in-app meter reconciles
"stream": trueand a large cached system promptmessage_deltaframe carries acostequal to thespendon the matching row from GET https://litellm-domain/spend/logsRelevant issues
Linear ticket
Resolves LIT-4902
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)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Both sides run a real proxy against real providers with real spend. The recorded number is read back through the gateway's own spend API rather than a callback file, so every figure below is one an operator can pull for themselves: the streaming response returns
x-litellm-call-id, and that value is the spend row'srequest_id, soGET /spend/logs?request_id=<id>pins each comparison to a single request.The deployment carries negotiated per-token prices well under sticker, and a 1h cache write rate distinct from the 5m one:
Sticker for the same model is
0.000003 / 0.000015 / 0.0000003 / 0.00000375 / 0.000006. The after run names the deploymentqa-sonnet, which has no entry in the cost map at all, so a sticker fallback could not quietly supply the number and any correct discounted cost has to come from the deployment's own pricing.Before (cc812cd, the merge base)
Cache read on
POST /v1/messages,x-litellm-call-id: 5c9902e3-8c5a-47cc-91a7-c0694e67d872:The recorded
0.0003787is12*1e-6 + 3467*1e-7 + 4*5e-6, the configured prices. The streamed0.0011001is3467*3e-7 + 4*1.5e-5, sticker rates with the 12 non-cached input tokens charged at zero. The stream claims 2.9x the spend.1h cache write on
POST /v1/messages,x-litellm-call-id: bef853eb-de56-4418-a69a-85eb64168858:Recorded spend
0.007016, which is12*1e-6 + 3492*2e-6 + 4*5e-6at the 1h rate. The streamed0.013155is3492*3.75e-6 + 4*1.5e-5, the 5m sticker rate applied to a genuine 1h write, 1.875x the spend.Pass-through on
POST /anthropic/v1/messages,x-litellm-call-id: 9163406d-b81f-47aa-b542-773e0edebabf, streamed0.0011001against a recorded0.0011361. Pass-through has no deployment, so sticker is correct here and only the dropped input tokens show: the gap is exactly12*3e-6 = 0.000036.After (655d107, the branch tip)
Six legs, every one showing the streamed
usage.costequal to the recorded spend.The 1h write leg is the sharpest of the six,
x-litellm-call-id: 2bc4d9ae-fe45-4684-b8f0-338116feddd7:17*1e-6 + 2576*2e-6 + 29*5e-6 = 0.005314on both sides. Had those 1h tokens been billed at the configured 5m write rate the answer would be0.003382, and at sticker0.015942, so this leg pins the tiered cache-write half of the fix on its own. The cache-read leg pins the other half:17*1e-6 + 2590*1e-7 + 32*5e-6 = 0.000436, with the 17 non-cached input tokens counted rather than dropped.The OpenAI pass-through leg is there because it is the only route that reaches the
chat.completion.chunkbranch. On a routed/v1/chat/completionsthe chunk arrives as aModelResponseStreamobject, which_process_chunk_with_cost_injectionskips, and the cost is already correct before this PR./openai/*delivers those frames as raw SSE throughPassThroughStreamingHandler, and it prices to1.845e-05on both sides, matching19*1.5e-7 + 26*6e-7.On the cost_breakdown restore
Pricing a frame through the request's own logging object also stamps
cost_breakdownonto that object, and the pass-through handlers compute their final cost with a barecompletion_cost._logging_obj_cost_or_nonetherefore snapshots that field, and the cost-failure debug key, and puts both back once the cost is read.That guard is defensive rather than a fix for something observable. Anthropic emits exactly one usage-bearing
message_delta, at the end of the stream, and onlymessage_deltais priced, so the single frame that gets priced already carries the full cumulative usage and a frame-derived breakdown is numerically identical to the correct one. Checked directly: the pass-through spend row carries a present and correctcost_breakdownboth at101ef7e167and at655d10775c, with no failure-debug key on either. The restore would start to matter for a provider that emits several usage-bearing frames mid-stream, or for a pricing failure that stamps the debug key.Type
🐛 Bug Fix
Caveats (if any)
Moderately serious. The cost still comes out wrong when no logging object reaches the injection point.
_streamed_usage_costpreferslitellm_logging_obj._response_cost_calculator, which knows the deployment and therefore its negotiated prices, and falls back to_completion_cost_or_none, which prices by model name off the public cost map. On the fallback the streamed number is sticker again and a discounted deployment will still disagree with its spend row. Every route exercised above takes the preferred path; the fallback is there so a missing logging object degrades to the old behavior instead of droppingcostentirely.Moderately serious. The arithmetic assumes Anthropic reports
input_tokensexclusive of cache tokens, so the rebuilt usage adds cache reads and cache writes on top of it. That matches what Anthropic documents and what the frames above show, and it is the same assumption the non-streaming path already makes, but a provider-side change to inclusive counting would double count the cached tokens rather than fail loudly.Minor. The
cost_breakdownrestore is defensive and changes nothing observable on any route tested here, for the reason set out above: the spend row's breakdown is present and correct both before and after. It is not covered by the e2e proof because there is no user-visible difference to capture. It earns its place by keeping a mid-stream pricing call from leaving a partial breakdown behind on a provider that emits more than one usage-bearing frame.Minor. The proof drives the routes with curl rather than an SDK. The thing under test is a field in the raw SSE frame, so curl reads exactly what any client's stream parser would hand to application code, and no SDK sits between the assertion and the bytes. An SDK run would add a layer without adding evidence.
Minor. The
/v1/chat/completionsleg runs against Anthropic direct rather than Bedrock. Bedrock on that route is broken onlitellm_internal_stagingindependently of this PR, unrelated to cost:converse_handler.pyreadscredentials.access_keybefore the bearer-token branch and raisesAttributeErroron a bearer-auth deployment. Filed as LIT-5928, not touched here. The Bedrock legs above go through/v1/messagesand/anthropic/v1/messages, which are unaffected.Final Attestation
Link to Devin session: https://app.devin.ai/sessions/177117598f214818bc610155fb711c6a
Requested by: @mateo-berri
Note
Medium Risk
Touches proxy streaming cost injection and live logging-object pricing, which can affect billed
usage.coston the wire. Snapshot/restore ofcost_breakdownis defensive, but a pricing-path bug could still misreport stream costs.Overview
Makes streamed
usage.costmatch the spend the gateway records wheninclude_cost_in_streaming_usageis on.Anthropic
message_deltaframes now rebuild usage viaAnthropicConfig.calculate_usageinstead of treatinginput_tokensas the full prompt. That bills uncached input plus cache reads, and keeps the 5m/1h cache-creation split so 1h writes are not priced at the cheaper 5m rate.Chunks are priced through the call’s
_response_cost_calculatorso custom deployment rates apply, with a fallback to model-name sticker pricing. Side effects oncost_breakdownand the cost-failure debug key are snapshotted and restored so a mid-stream price does not leak into the spend log. The logging object is threaded through both the chat stream path and pass-through SSE.Reviewed by Cursor Bugbot for commit 655d107. Bugbot is set up for automated code reviews on this repo. Configure here.