Skip to content

fix(streaming): price partial-stream spend rows at the real model and keep prompt and cache fields - #37734

Merged
mateo-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_fix_partial_stream_spend_rows
Aug 21, 2026
Merged

fix(streaming): price partial-stream spend rows at the real model and keep prompt and cache fields#37734
mateo-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_fix_partial_stream_spend_rows

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Streams cut mid-response log "spend": 0.0 despite billable tokens
  • Proxy-side stream-cap rows also log prompt_tokens: 0
  • Partial rows drop the cache token fields full rows carry

How it solves it:

  • Price the partial rebuild at the deployment's real model, not the client alias
  • Pass the request messages so prompt tokens get counted
  • Carry the cache token fields onto partial rows, with the provider's real counts where it sent them

User Flow

Before: a developer whose stream gets cut mid-response is billed nothing for it, and the spend row loses token detail too

  1. They send POST https://litellm-domain/v1/chat/completions with "model": "bedrock-claude-opus-5" and "stream": true, and text chunks start arriving
  2. Their client gives up at its own 12 second timeout and closes the connection after ~126 chunks
  3. GET https://litellm-domain/spend/logs?request_id=chatcmpl-... shows the row with 296 completion tokens but "spend": 0.0, and its usage object is missing the cache token fields every completed request reports at 0
  4. On a proxy configured to cap stream duration at 6 seconds, the same request ends with a 408 error chunk instead, and that row is worse: "prompt_tokens": 0, "spend": 0.0, usage object null

After: the same cut streams get priced at the model's real rates and keep their token detail

  1. They send the same POST https://litellm-domain/v1/chat/completions with "model": "bedrock-claude-opus-5" and "stream": true
  2. Their client gives up at its 12 second timeout, same as before
  3. GET https://litellm-domain/spend/logs?request_id=chatcmpl-... now shows non-zero spend for the streamed completion tokens, priced at the underlying model's per-token rates, with the cache token fields present at 0 like any completed request
  4. The proxy-cap 408 row now carries the real prompt token count and non-zero spend for the tokens streamed before the cut

Relevant issues

Linear ticket

Resolves LIT-5840

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. 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
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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

Live runs against Bedrock us.anthropic.claude-opus-5, real spend, no mocks. Shared setup for every case below:

config.yaml

model_list:
  - model_name: bedrock-claude-opus-5
    litellm_params:
      model: bedrock/us.anthropic.claude-opus-5
      aws_region_name: us-east-1

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY
  database_url: os.environ/DATABASE_URL

Every spend row below is read back the way a user would, through GET /spend/logs?request_id=<id> with the master key, piped to jq for the fields in question. Rates for pricing checks: input $5.5e-06, output $2.75e-05 per token.

Before (9432f40)

Case A: client hangs up mid-stream on /v1/chat/completions

  1. Send a control request that runs to completion:
curl -s -N "http://127.0.0.1:PORT/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"bedrock-claude-opus-5","stream":true,"stream_options":{"include_usage":true},"max_tokens":600,
       "messages":[{"role":"user","content":"Write about 400 words on how rivers shape landscapes."}]}'

a1 response id: chatcmpl-17f0bb81-d88d-4bb3-9a19-935db3d12e9c, chunks: 155
  1. Send the same kind of request and hang up at 12 seconds:
curl -s -N --max-time 12 "http://127.0.0.1:PORT/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"bedrock-claude-opus-5","stream":true,"stream_options":{"include_usage":true},"max_tokens":4000,
       "messages":[{"role":"user","content":"Write 1500 words about the history of lighthouses."}]}'

curl exit: 28
b1 response id: chatcmpl-736193e3-a7d9-4d92-8a18-6f5757cf3b13, chunks: 126
  1. Read the control row, which is priced and carries the cache fields at 0:
{
  "request_id": "chatcmpl-17f0bb81-d88d-4bb3-9a19-935db3d12e9c",
  "status": "success",
  "prompt_tokens": 25,
  "completion_tokens": 600,
  "spend": 0.0166375,
  "model": "bedrock/us.anthropic.claude-opus-5",
  "usage_object": {
    "total_tokens": 625, "prompt_tokens": 25, "completion_tokens": 600,
    "prompt_tokens_details": {"text_tokens": 25, "cached_tokens": 0, "cache_write_tokens": 0, "cache_creation_tokens": 0},
    "cache_read_input_tokens": 0,
    "cache_creation_input_tokens": 0
  }
}
  1. Read the row for the stream that got hung up on, which counted 296 completion tokens and billed nothing, with no cache fields at all:
{
  "request_id": "chatcmpl-736193e3-a7d9-4d92-8a18-6f5757cf3b13",
  "status": "success",
  "prompt_tokens": 20,
  "completion_tokens": 296,
  "spend": 0.0,
  "model": "bedrock/us.anthropic.claude-opus-5",
  "usage_object": {
    "total_tokens": 316, "prompt_tokens": 20, "completion_tokens": 296,
    "prompt_tokens_details": null,
    "completion_tokens_details": null
  },
  "error_information": {"error_code": "499", "error_class": "ClientDisconnected", "error_message": "Client disconnected the request"}
}

Case B: proxy cuts the stream at its duration cap on /v1/chat/completions

  1. Boot the proxy with LITELLM_MAX_STREAMING_DURATION_SECONDS=6 and send a long stream:
curl -s -N -w "total=%{time_total}s http=%{http_code}\n" "http://127.0.0.1:PORT/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"bedrock-claude-opus-5","stream":true,"stream_options":{"include_usage":true},"max_tokens":4000,"thinking":{"type":"disabled"},
       "messages":[{"role":"user","content":"Write 1500 words about the history of lighthouses."}]}'

total=8.071515s http=200
x-litellm-call-id: c50380a4-d092-4625-8bfc-46281a8d1fd3
chunks: 99
data: {"error": {"message": "litellm.Timeout: Stream exceeded max streaming duration of 6.0s (elapsed 6.3s)", "type": null, "param": null, "code": "408"}}
  1. Read the row: no prompt tokens, no spend, no usage object, though 219 completion tokens streamed:
{
  "request_id": "c50380a4-d092-4625-8bfc-46281a8d1fd3",
  "status": "failure",
  "prompt_tokens": 0,
  "completion_tokens": 219,
  "spend": 0.0,
  "model": "bedrock/us.anthropic.claude-opus-5",
  "usage_object": null,
  "error_information": {"error_code": "408", "error_class": "Timeout", "llm_provider": "bedrock",
    "error_message": "litellm.Timeout: Stream exceeded max streaming duration of 6.0s (elapsed 6.3s)"}
}

Case C: proxy cuts the stream at its duration cap on /v1/responses

  1. Same proxy, same cap, on the responses route:
curl -s -N -w "total=%{time_total}s http=%{http_code}\n" "http://127.0.0.1:PORT/v1/responses" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"bedrock-claude-opus-5","stream":true,"max_output_tokens":4000,
       "input":"Write 1500 words about the history of lighthouses."}'

total=7.558630s http=200
x-litellm-call-id: 9af3d402-257c-4c88-9782-f3c79129de21
chunks: 36
data: {"error": {"message": "litellm.Timeout: Stream exceeded max streaming duration of 6.0s (elapsed 6.0s)", "code": "408"}}
  1. Read the row: prompt tokens 0 again, so the 81 streamed output tokens are the only thing billed:
{
  "request_id": "9af3d402-257c-4c88-9782-f3c79129de21",
  "status": "failure",
  "prompt_tokens": 0,
  "completion_tokens": 81,
  "spend": 0.0022275,
  "model": "bedrock-claude-opus-5",
  "model_group": "",
  "usage_object": null,
  "error_information": {"error_code": "408", "error_class": "Timeout", "llm_provider": "bedrock"}
}

After (0801493)

Case A: client hangs up mid-stream on /v1/chat/completions

  1. Same control request:
curl -s -N "http://127.0.0.1:PORT/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"bedrock-claude-opus-5","stream":true,"stream_options":{"include_usage":true},"max_tokens":600,
       "messages":[{"role":"user","content":"Write about 400 words on how rivers shape landscapes."}]}'

a1 response id: chatcmpl-72ff40bb-b926-4774-8bd3-07c06e37ab83, chunks: 149
  1. Same request hung up on at 12 seconds:
curl -s -N --max-time 12 "http://127.0.0.1:PORT/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"bedrock-claude-opus-5","stream":true,"stream_options":{"include_usage":true},"max_tokens":4000,
       "messages":[{"role":"user","content":"Write 1500 words about the history of lighthouses."}]}'

curl exit: 28
b1 response id: chatcmpl-80db78a2-cc2c-4048-bc54-e4906040d659, chunks: 142
  1. Control row, unchanged, priced at 25 * 5.5e-06 + 600 * 2.75e-05 = 0.0166375:
{
  "request_id": "chatcmpl-72ff40bb-b926-4774-8bd3-07c06e37ab83",
  "status": "success",
  "prompt_tokens": 25,
  "completion_tokens": 600,
  "spend": 0.0166375,
  "model": "bedrock/us.anthropic.claude-opus-5",
  "usage_object": {
    "total_tokens": 625, "prompt_tokens": 25, "completion_tokens": 600,
    "prompt_tokens_details": {"text_tokens": 25, "cached_tokens": 0, "cache_write_tokens": 0, "cache_creation_tokens": 0},
    "cache_read_input_tokens": 0,
    "cache_creation_input_tokens": 0
  },
  "error_information": null
}
  1. The hung-up row is now billed at the model's real rates, 20 * 5.5e-06 + 316 * 2.75e-05 = 0.0088, and carries the same cache fields at 0:
{
  "request_id": "chatcmpl-80db78a2-cc2c-4048-bc54-e4906040d659",
  "status": "success",
  "prompt_tokens": 20,
  "completion_tokens": 316,
  "spend": 0.0088,
  "model": "bedrock/us.anthropic.claude-opus-5",
  "usage_object": {
    "total_tokens": 336, "prompt_tokens": 20, "completion_tokens": 316,
    "prompt_tokens_details": {"cached_tokens": 0},
    "cache_read_input_tokens": 0,
    "cache_creation_input_tokens": 0
  },
  "error_information": {"error_code": "499", "error_class": "ClientDisconnected", "error_message": "Client disconnected the request"}
}

Case B: proxy cuts the stream at its duration cap on /v1/chat/completions

  1. Same cap, same request:
curl -s -N -w "total=%{time_total}s http=%{http_code}\n" "http://127.0.0.1:PORT/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"bedrock-claude-opus-5","stream":true,"stream_options":{"include_usage":true},"max_tokens":4000,"thinking":{"type":"disabled"},
       "messages":[{"role":"user","content":"Write 1500 words about the history of lighthouses."}]}'

total=8.227816s http=200
x-litellm-call-id: 79786692-c5dd-4d33-a31d-1bc367faae2e
chunks: 99
data: {"error": {"message": "litellm.Timeout: Stream exceeded max streaming duration of 6.0s (elapsed 6.1s)", "type": null, "param": null, "code": "408"}}
  1. The row now has a real prompt token count and is billed 20 * 5.5e-06 + 232 * 2.75e-05 = 0.00649:
{
  "request_id": "79786692-c5dd-4d33-a31d-1bc367faae2e",
  "status": "failure",
  "prompt_tokens": 20,
  "completion_tokens": 232,
  "spend": 0.00649,
  "model": "bedrock/us.anthropic.claude-opus-5",
  "model_group": "bedrock-claude-opus-5",
  "usage_object": null,
  "error_information": {"error_code": "408", "error_class": "Timeout", "llm_provider": "bedrock",
    "error_message": "litellm.Timeout: Stream exceeded max streaming duration of 6.0s (elapsed 6.1s)"}
}

Case C: proxy cuts the stream at its duration cap on /v1/responses

  1. Same proxy, same cap, on the responses route:
curl -s -N -w "total=%{time_total}s http=%{http_code}\n" "http://127.0.0.1:PORT/v1/responses" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"bedrock-claude-opus-5","stream":true,"max_output_tokens":4000,
       "input":"Write 1500 words about the history of lighthouses."}'

total=7.906010s http=200
x-litellm-call-id: 1f44b81d-c4d4-4a0a-82be-24ab1b52dcc6
chunks: 66
data: {"error": {"message": "litellm.Timeout: Stream exceeded max streaming duration of 6.0s (elapsed 6.0s)", "code": "408"}}
  1. Prompt tokens are counted here too now, so the row is billed 20 * 5.5e-06 + 116 * 2.75e-05 = 0.0033:
{
  "request_id": "1f44b81d-c4d4-4a0a-82be-24ab1b52dcc6",
  "status": "failure",
  "prompt_tokens": 20,
  "completion_tokens": 116,
  "spend": 0.0033,
  "model": "bedrock-claude-opus-5",
  "model_group": "",
  "usage_object": null,
  "error_information": {"error_code": "408", "error_class": "Timeout", "llm_provider": "bedrock",
    "error_message": "litellm.Timeout: Stream exceeded max streaming duration of 6.0s (elapsed 6.0s)"}
}

Notes from the runs

  • /v1/messages streams ignore the duration cap, so no partial row there
  • Failure rows leave usage_object null; detail sits elsewhere in metadata
  • /v1/responses rows log the alias as model, model_group empty
  • None of those three is changed by this PR
  • The lighthouse prompt counts 20 prompt tokens on every row here; the control row's 25 is a different prompt

Type

🐛 Bug Fix

Caveats (if any)

  • A partial row now carries the provider's real cache counts when the stream reported them, and zero otherwise. cost_calculator.py has a branch that reads the presence of cache_read_input_tokens as "this usage object is Anthropic-shaped" and folds the cache counts back into prompt tokens. It only runs when the caller passes custom_cost_per_token or custom_cost_per_second, and the partial-stream path passes neither, so it cannot fire here. I left it alone rather than change shared cost code every caller goes through
  • The two tests that check cache counts survive from the chunks also pass at the merge base. They are there so a later change cannot go back to zeroing the fields unconditionally, not to demonstrate this fix
  • The disconnect path keeps the model the chunk builder found on a later chunk only when it is not the name the client sent. That is what tells an Azure Model Router stream apart from an alias-restamped one, since both reach billing with a first chunk that disagrees with the rest: a chunk carrying usage is stored as a copy before the proxy restamps the one it forwards. No automated test drives the proxy's own restamp, so the unit tests build both shapes by editing the stored chunks
  • The proxy-cut failure path in streaming_handler.py has no access to the client's model name, so it always prices at the deployment's model. An Azure Model Router stream cut by the duration cap is billed at the router deployment rather than the routed model, which is what a completed stream on that route logs today
  • Passing messages into the partial rebuild changes the prompt token count on every interrupted async stream, SDK callers included, not only proxy rows. token_counter raises on messages=None and ChunkProcessor.calculate_usage turns that into prompt_tokens = 0, so the old number was exactly zero whenever no chunk reported prompt tokens. It is a tiktoken estimate now, which can differ from the provider's own count for non-OpenAI tokenizers or when tools and system content sit outside messages

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Touches spend tracking for interrupted streams (disconnect and mid-flight failure). Wrong model selection could misprice rows, but the change is isolated to partial-billing paths and is covered by unit tests.

Overview
Interrupted streams (client disconnect or mid-flight failure) now log non-zero spend at the deployment model, not an unpriced public alias, and keep prompt/cache token fields that complete rows already report.

Pricing. _record_partial_usage_for_failure restamps the rebuilt response with self.model before cost calc, and passes request messages so prompt tokens are counted. Disconnect billing in _bill_partial_streamed_spend_on_disconnect prefers the wrapper’s real model unless Azure Model Router recovered a later-chunk routed name (distinguished from alias restamping via _litellm_client_requested_model).

Cache fields. New backfill_missing_cache_usage_fields copies OpenAI-style prompt_tokens_details counts onto Anthropic-style cache_read_input_tokens / cache_creation_input_tokens (or zeros them) so partial rows match complete ones without overwriting real cache counts.

Reviewed by Cursor Bugbot for commit 0801493. Bugbot is set up for automated code reviews on this repo. Configure here.

… keep prompt and cache fields

A streaming chat completion that ends early (client disconnect, or the proxy
cutting the stream at LITELLM_MAX_STREAMING_DURATION_SECONDS) wrote a spend log
row with spend 0.0, prompt_tokens 0 on the proxy-cut path, and no cache fields
in usage_object. The proxy restamps chunk.model in place to the client-facing
alias, so the partial response rebuilt from those chunks priced the unmapped
alias and came out at 0. The failure path also rebuilt usage without the
request messages, so prompt tokens counted to 0, and a cut stream never sees
the final usage event that normally zero-fills the cache fields.

Restamp the rebuilt partial response with the wrapper's real model before cost
calculation on both the disconnect and the failure paths, pass the request
messages when rebuilding usage on the failure path, and zero-fill missing
cache usage fields the way completed streams already do.
@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR improves accounting for interrupted streams by rebuilding prompt and cache usage and selecting a priceable deployment model.

  • Passes request messages into partial-response reconstruction so prompt tokens are retained.
  • Backfills missing cache-token fields without replacing provider-supplied values.
  • Restores deployment-model pricing while preserving dynamically routed model attribution on disconnects.
  • Adds regression coverage for alias pricing, routed models, prompt reconstruction, and cache metadata.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up-review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/litellm_core_utils/streaming_handler.py Rebuilds partial failure usage with request messages, deployment-model attribution, and cache-token metadata.
litellm/proxy/common_request_processing.py Updates disconnect billing to select between the wrapper deployment model and a model recovered from routed chunks.
tests/test_litellm/litellm_core_utils/test_streaming_handler.py Adds coverage for partial-stream pricing, prompt token reconstruction, and cache-field preservation.
tests/test_litellm/proxy/test_common_request_processing.py Adds disconnect-billing tests for aliases, Azure Model Router attribution, and cache usage.

Reviews (5): Last reviewed commit: "Match the client-name check to the name ..." | Re-trigger Greptile

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.22222% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/litellm_core_utils/streaming_handler.py 95.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_partial_stream_spend_rows (0801493) with litellm_internal_staging (65b4ac0)

Open in CodSpeed

cache_read_input_tokens and cache_creation_input_tokens are pydantic extras
on Usage, not declared fields, so filling them in created keys that were not
there before rather than replacing a None. Readers that test for presence
then took the new zero as authoritative: the spend log writer skipped its
own copy from prompt_tokens_details, turning a real cache read of 500 into
0, and the prometheus provider cache counters stopped incrementing.

Carry the prompt_tokens_details counts up before defaulting to zero, so a
partial row reports the same cache numbers a complete one does. Renamed the
helper to say what it now does.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 de7dcbb. Configure here.

The disconnect billing path was stamping the wrapper's model over whatever
stream_chunk_builder assembled. For Azure Model Router that throws away the
routed model: the proxy deliberately leaves those chunks unrestamped so the
builder can pick the real model off a later chunk, and overwriting it prices
the row at the router alias instead.

Only apply the wrapper's model when the builder did not find a model beyond
the first chunk's, which is every case except Model Router.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 03a253a. Configure here.

A chunk carrying usage is stored as a pre-restamp copy, so an alias-restamped
stream reaches disconnect billing with its first chunk still on the deployment
model and every later chunk on the client's name. That is the same shape Azure
Model Router produces, and the previous guard read it as a routed model and
left the alias on the row, which is the unpriced name this PR set out to stop.

Compare the assembled model against the name the proxy stamps chunks with, so
the alias goes back to the deployment's model and the routed model stays.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Client-name check uses rewritten model
    • Restricted _assembled_model_is_the_name_the_client_asked_for to key off the preserved _litellm_client_requested_model (falling back to request_data['model'] only when the preserved key is absent) so a rewritten deployment id no longer masquerades as the client-asked name and clobbers later-chunk router recovery.

Create PR

Or push these changes by commenting:

@cursor push 20165c0e12
Preview (20165c0e12)
diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py
--- a/litellm/proxy/common_request_processing.py
+++ b/litellm/proxy/common_request_processing.py
@@ -304,11 +304,16 @@
 
     That stamp is what leaves an unpriced alias on the partial response, so the deployment's
     own model has to go back on before the row is costed.
+
+    Prefer the preserved client-requested name over ``request_data["model"]`` because pre-call
+    alias and routing rewrites can replace the latter with the deployment target; matching that
+    rewritten id would misclassify an Azure Model Router style later-chunk recovery as an alias
+    restamp and stamp the wrapper's model over it.
     """
-    return assembled_model in (
-        request_data.get("_litellm_client_requested_model"),
-        request_data.get("model"),
-    )
+    client_requested: Final = request_data.get("_litellm_client_requested_model")
+    if isinstance(client_requested, str):
+        return assembled_model == client_requested
+    return assembled_model == request_data.get("model")
 
 
 async def _bill_partial_streamed_spend_on_disconnect(request_data: dict, response: object) -> bool:

diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py
--- a/tests/test_litellm/proxy/test_common_request_processing.py
+++ b/tests/test_litellm/proxy/test_common_request_processing.py
@@ -5593,6 +5593,32 @@
         assert standard_logging_object["response_cost"] > 0.0
 
     @pytest.mark.asyncio
+    async def test_disconnect_billing_router_recovery_survives_pre_call_model_rewrite(self):
+        """
+        Pre-call routing can rewrite ``request_data["model"]`` to the deployment id while
+        preserving the client alias in ``_litellm_client_requested_model``. The
+        client-asked-for gate must key off the preserved name so that an Azure Model
+        Router style later-chunk recovery is not misread as an alias restamp and
+        stomped by the wrapper's model.
+        """
+        def restamp_like_azure_model_router(response):
+            response.chunks[0].model = "azure-model-router"
+            for chunk in response.chunks[1:]:
+                chunk.model = "gpt-4.1-nano-2025-04-14"
+
+        event = await self._bill_and_collect_success_event(
+            restamp_like_azure_model_router,
+            request_data={
+                "model": "gpt-4.1-nano-2025-04-14",
+                "_litellm_client_requested_model": "azure-model-router",
+            },
+        )
+
+        assert event["response_obj"].model == "gpt-4.1-nano-2025-04-14"
+        standard_logging_object = event["kwargs"]["standard_logging_object"]
+        assert standard_logging_object["response_cost"] > 0.0
+
+    @pytest.mark.asyncio
     async def test_disconnect_billing_backfills_missing_cache_fields(self):
         event = await self._bill_and_collect_success_event()

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/common_request_processing.py
Pre-call processing rewrites request_data["model"] for aliasing and routing, so
matching either key let a routed model count as the client's own name and put the
wrapper model back on an Azure Model Router row.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 0801493. Configure here.

@mateo-berri
mateo-berri enabled auto-merge August 21, 2026 02:40

@tin-berri tin-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid billing-correctness fix. The core subtlety — distinguishing "stream_chunk_builder recovered the real routed model from later chunks" (Azure Model Router) from "chunks got restamped to the client-facing alias" — is handled correctly via _assembled_model_came_from_a_later_chunk + _assembled_model_is_the_name_the_client_asked_for, and the trickiest edge case (pre-call processing already rewrote request_data["model"] to the routed name) is explicitly tested via the _litellm_client_requested_model fallback — without it the naive comparison against request_data["model"] would silently misattribute the routed model back to the alias. Cache-field backfill correctly carries OpenAI-style prompt_tokens_details.cached_tokens up to the Anthropic-style top-level keys, defaults to real zero only when genuinely absent, and is proven not to clobber values already recovered from chunks. Test coverage is thorough — both the helper-level cases and full disconnect-billing integration tests asserting real response_cost > 0 and correct model attribution. CI green. Approved.

@mateo-berri
mateo-berri merged commit a66a10b into litellm_internal_staging Aug 21, 2026
74 checks passed
@mateo-berri
mateo-berri deleted the litellm_fix_partial_stream_spend_rows branch August 21, 2026 02:48
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.

2 participants