Skip to content

fix(proxy): request stream usage upstream by default and strip it from client streams - #35290

Merged
mateo-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_stream_usage_by_default
Jul 31, 2026
Merged

fix(proxy): request stream usage upstream by default and strip it from client streams#35290
mateo-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_stream_usage_by_default

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Streams without stream_options.include_usage get tiktoken-estimated usage
  • Hidden reasoning tokens are invisible to tiktoken, so SpendLogs undercounts output massively
  • On reasoning models the gap reaches 90%+ of billed output tokens

How it solves it:

  • Proxy injects include_usage upstream on /v1/chat/completions streams
  • Injection only happens when every deployment behind the requested model declares stream_options in its supported OpenAI params, so providers that reject the param (Bytez, OCI) or that stream usage natively (Anthropic, Gemini, Bedrock) are left untouched
  • Injection artifacts are stripped, so client-visible SSE stays byte-identical
  • The internal strip marker is neutralized when it arrives in a client request body, so clients cannot suppress a usage chunk they asked for
  • always_include_stream_usage: false remains a kill switch, true keeps forwarding usage

Relevant issues

Linear ticket

Pre-Submission checklist

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

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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

All runs hit a live proxy backed by real Azure OpenAI and Anthropic APIs, with SpendLogs in Postgres. Before runs were captured at abd239f903 (current litellm_internal_staging), after runs at this PR's head c0de87d08d. Config mirrors an affected deployment, with no always_include_stream_usage set:

model_list:
  - model_name: azure-nano
    litellm_params:
      model: "azure/gpt-5.4-nano"
      api_base: os.environ/AZURE_API_BASE
      api_key: os.environ/AZURE_API_KEY
      api_version: "2025-04-01-preview"
  - model_name: anthropic-fable
    litellm_params:
      model: "anthropic/claude-fable-5"
      api_key: os.environ/ANTHROPIC_API_KEY
general_settings:
  store_model_in_db: True

The client request never sends stream_options:

curl -s -N http://localhost:47211/v1/chat/completions \
  -H "Authorization: Bearer sk-..." -H 'Content-Type: application/json' \
  -d '{"model":"azure-nano","messages":[{"role":"user","content":"What is 47*53 minus 19? Reply with just the number."}],"stream":true,"reasoning_effort":"medium"}'

SpendLogs rows across the QA matrix (psql -d llnl_fix_qa -c 'SELECT model, prompt_tokens, completion_tokens, spend FROM "LiteLLM_SpendLogs" ORDER BY "startTime"'):

leg commit config pt ct spend
azure pong before default 12 1 $0.00000365
azure pong after default 11 5 $0.00000845
azure reasoning before flag false 23 2 $0.00000710
azure reasoning after default 22 40 $0.00005440
anthropic pong before default 16 4 $0.00036000
anthropic pong after default 16 4 $0.00036000

The before rows are tiktoken estimates: the reasoning request produced a 2-token visible answer ("2472") while Azure billed 40 output tokens, so 38 reasoning tokens and 87% of the spend were silently dropped. The after rows match provider-billed usage exactly. Anthropic streams usage natively and its route is untouched by this PR (the support gate skips it entirely), so its numbers were correct before and stay identical

Client-visible SSE frames for the same azure request, before vs after, both with zero usage frames and identical shapes (verified with diff over the captured streams):

{"choices":[{"d":{"role":"assistant","content":""},"fr":null}],"usage":false}
{"choices":[{"d":{"content":"pong"},"fr":null}],"usage":false}
{"choices":[{"d":{},"fr":"stop"}],"usage":false}
IDENTICAL

Clients that do opt in still get the usage chunk, at both commits:

curl -s -N ... -d '{"model":"azure-nano","messages":[...],"stream":true,"stream_options":{"include_usage":true}}'
# final frame: {"choices":[...],"usage":{"prompt_tokens":11,"completion_tokens":5,...}}

always_include_stream_usage: true still forwards the usage chunk to clients that never asked (existing behavior, verified live), and an explicit false disables the upstream injection entirely, reproducing the old estimated row (23 pt, 2 ct) for deployments whose OpenAI-compatible backend rejects stream_options

Providers whose param map rejects stream_options are skipped by the support gate instead of erroring. With a Bytez model in the same config (fake key, so the request dies at the provider's door either way), streaming at 43efd02d35 failed before ever reaching Bytez:

{"error":{"message":"litellm.APIConnectionError: param `stream_options` is not supported on Bytez ..."}}

while at c0de87d08d the identical request passes param mapping and reaches the real Bytez API:

{"error":{"message":"litellm.APIConnectionError: BytezException - {\"error\":\"Unauthorized\"} ..."}}

Team-scoped models resolve through the gate too. With a team model created via /team/new + /model/new (internal name model_name_{team_id}_{uuid}, public alias team-gpt) and streamed through a team key, the reasoning request at 0732122536 logged the tiktoken estimate (23 pt, 2 ct, $0.00000710) because the gate resolved the alias without team_id and skipped injection; at c0de87d08d the identical request logs provider-billed usage (22 pt, 38 ct, $0.00005190) with client-visible frames unchanged

Type

🐛 Bug Fix

Changes

_stream_usage_tracking_updates in common_request_processing.py replaces the old flag-gated injection block. With always_include_stream_usage unset it injects stream_options.include_usage: true into /v1/chat/completions streams (merging with any client-sent stream_options) and marks the request with an internal _litellm_strip_stream_usage key; true keeps the exact previous inject-and-forward semantics, and false now opts out of injection entirely. The route scoping matters because other streaming surfaces (Anthropic /v1/messages, Google generateContent) already receive provider usage without asking and their generators do not strip

Default injection is additionally gated on provider support: _model_deployments_support_stream_options resolves the requested model through the router (deployments, aliases, and wildcards via get_model_list) and only allows injection when every backing deployment lists stream_options in get_supported_openai_params. stream_options is exempt from the generic UnsupportedParamsError check, so the only providers that can break are ones whose map_openai_params explicitly raises (Bytez, OCI); those, and providers that stream usage natively and never needed the flag (Anthropic, Gemini, Bedrock), now keep their exact pre-PR behavior. The gate resolves with the request's team_id, since team-scoped models store an internal model_name_{team_id}_{uuid} name and are only reachable through their team_public_model_name when the team is known. Unresolvable models conservatively skip injection, falling back to today's estimates rather than risking a provider error

_litellm_strip_stream_usage is internal, so a value arriving in the client request body is neutralized: _stream_usage_tracking_updates overwrites any inbound value (only the proxy's own injection path can set it to true), and the experimental /queue/chat/completions endpoint, which bypasses that pre-call logic, pops it from the parsed body. A client can no longer plant the marker to make the proxy swallow a usage chunk it explicitly requested

async_data_generator in proxy_server.py drops injection artifacts when the marker is set: chunks whose choices are all empty (no delta content, finish_reason, or logprobs) and that carry no provider_specific_fields. That covers both the provider's final usage chunk and the empty prompt-filter chunk Azure only emits when include_usage is on, which live testing showed would otherwise leak. Chunks with real payload are never touched, even when a provider attaches usage to them

_litellm_strip_stream_usage is registered in all_litellm_params so get_non_default_completion_params treats it as LiteLLM-internal instead of sweeping it into the provider request body

Tests pin the tri-state config semantics, the artifact predicate against real chunk shapes (synthetic usage chunk, empty-choices usage chunk, prompt-filter chunk, content and finish chunks with usage attached), and the generator end to end: with the marker the usage chunk disappears from client SSE, without it the chunk is forwarded. The support gate is covered against real Router instances (Azure deployment, Bytez deployment, mixed-provider model group, wildcard route, unmapped alias, and a team-alias model that only resolves when team_id is passed), and the marker neutralization against client-planted values on the default, flag-true, and non-streaming paths

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
Changes the default proxy streaming request/response path and spend-logging inputs for chat completions, but provider support gating and conservative skip behavior limit breakage; extensive tests cover injection, stripping, and router/team resolution.

Overview
Default /v1/chat/completions streaming now asks the provider for real usage by injecting stream_options.include_usage when always_include_stream_usage is unset, while keeping the client stream unchanged by marking the request with internal _litellm_strip_stream_usage and dropping empty usage-only SSE chunks in async_data_generator.

Injection is scoped to acompletion and gated on router-resolved deployments (including team aliases): every backing model must advertise stream_options in get_supported_openai_params, so providers that reject the param or already stream usage natively are skipped. always_include_stream_usage: true still injects and forwards usage; false disables injection entirely.

Client-supplied _litellm_strip_stream_usage is neutralized on the normal pre-call path and stripped on the queued chat-completions endpoint; the flag is registered in all_litellm_params so it is not forwarded upstream.

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

…m client streams

Streamed chat completions that did not opt into stream_options.include_usage
were logged with tiktoken estimates over the visible text, so hidden
reasoning tokens (billed as output by OpenAI-compatible providers) were
never counted and SpendLogs could undercount output tokens by 90%+ on
reasoning models. The proxy now injects include_usage upstream for
/v1/chat/completions streams by default and strips the injection artifacts
(the final usage chunk and the empty prompt-filter chunk) from the
client-facing SSE stream, so accounting uses provider-billed usage while
the client-visible stream stays byte-identical to today.

always_include_stream_usage keeps its existing semantics: true forwards
the usage chunk to clients as before, and an explicit false now acts as a
kill switch that disables the upstream injection for OpenAI-compatible
backends that reject stream_options.
Comment thread litellm/proxy/common_request_processing.py
Comment thread litellm/proxy/proxy_server.py
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR requests upstream stream usage by default while preserving the client-visible streaming contract.

  • Gates automatic stream_options.include_usage injection on route, configuration, and deployment support.
  • Marks automatically injected requests and removes usage-only artifacts from downstream SSE.
  • Neutralizes client-supplied internal strip markers, including on the queue endpoint.
  • Adds coverage for configuration semantics, provider support resolution, marker handling, and stream filtering.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/proxy/common_request_processing.py Adds support-gated stream usage injection and neutralizes untrusted strip-marker values; the previously reported unsupported-provider issue is addressed.
litellm/proxy/proxy_server.py Filters automatically induced empty usage artifacts and removes the internal marker from queue request bodies; the previously reported client-controlled stripping issue is addressed.
litellm/types/utils.py Registers the strip marker as an internal LiteLLM parameter so it is not forwarded as a provider parameter.
tests/test_litellm/proxy/test_common_request_processing.py Adds focused tests for tri-state configuration, provider support gating, route scoping, team aliases, and marker neutralization.
tests/test_litellm/proxy/test_proxy_server.py Adds predicate and generator-level coverage proving marked usage artifacts are removed while content and finish chunks remain visible.

Reviews (4): Last reviewed commit: "fix(proxy): resolve team-alias models in..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.15385% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/proxy_server.py 77.27% 5 Missing ⚠️
litellm/proxy/common_request_processing.py 90.69% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

…nd neutralize client-sent strip marker

Bytez and OCI param maps raise on stream_options when drop_params is
unset, so the default injection would have broken every streamed chat
completion routed to them. Injection now only happens when every router
deployment behind the requested model (wildcards and aliases included)
declares stream_options in its supported OpenAI params; providers that
do not declare it either reject the param or already stream usage
natively, so skipping them keeps old behavior instead of erroring.

_litellm_strip_stream_usage arriving in the client request body is now
overwritten at ingress (and popped in the experimental queue endpoint),
so a client can no longer suppress the usage chunk it explicitly
requested by planting the internal marker.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@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: Team models skip usage injection
    • Threaded team_id through _model_deployments_support_stream_options and passed user_api_key_dict.team_id at the call site so team-scoped models resolve via team_public_model_name and include_usage is injected as intended.

Create PR

Or push these changes by commenting:

@cursor push b9bbf248e5
Preview (b9bbf248e5)
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
@@ -270,10 +270,13 @@
 def _model_deployments_support_stream_options(
     model: object,
     llm_router: Optional[Router],
+    team_id: Optional[str] = None,
 ) -> bool:
     if not isinstance(model, str):
         return False
-    deployments = llm_router.get_model_list(model_name=model) if llm_router is not None else None
+    deployments = (
+        llm_router.get_model_list(model_name=model, team_id=team_id) if llm_router is not None else None
+    )
     deployment_models = tuple(
         litellm_model
         for deployment in deployments or ()
@@ -1310,6 +1313,7 @@
                 supports_stream_options=lambda: _model_deployments_support_stream_options(
                     model=self.data.get("model"),
                     llm_router=llm_router,
+                    team_id=user_api_key_dict.team_id,
                 ),
             )
         )

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
@@ -5265,12 +5265,14 @@
 
 
 class TestModelDeploymentsSupportStreamOptions:
-    def _support(self, model, llm_router=None) -> bool:
+    def _support(self, model, llm_router=None, team_id=None) -> bool:
         from litellm.proxy.common_request_processing import (
             _model_deployments_support_stream_options,
         )
 
-        return _model_deployments_support_stream_options(model=model, llm_router=llm_router)
+        return _model_deployments_support_stream_options(
+            model=model, llm_router=llm_router, team_id=team_id
+        )
 
     def test_openai_compatible_deployment_supports_stream_options(self):
         router = litellm.Router(
@@ -5337,3 +5339,23 @@
 
     def test_non_string_model_is_not_injected(self):
         assert self._support(None, None) is False
+
+    def test_team_public_model_name_resolves_via_team_id(self):
+        # Regression: team-scoped models keyed by (team_id, team_public_model_name)
+        # were previously unresolved because the support gate omitted team_id,
+        # so include_usage injection was skipped for common multi-tenant setups.
+        router = litellm.Router(
+            model_list=[
+                {
+                    "model_name": "internal-team-model-uuid",
+                    "litellm_params": {"model": "openai/gpt-4o", "api_key": "fake"},
+                    "model_info": {
+                        "team_id": "team-a",
+                        "team_public_model_name": "team-a-gpt",
+                    },
+                }
+            ]
+        )
+
+        assert self._support("team-a-gpt", router, team_id="team-a") is True
+        assert self._support("team-a-gpt", router, team_id=None) is False

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

Comment thread litellm/proxy/common_request_processing.py
The single-element tuple loop that bound the extracted deployment model
inside the comprehension read poorly; an assignment expression in the
filter clause does the same call-once-and-filter in one line.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Team-scoped models store an internal model_name_{team_id}_{uuid} name
with the public alias only in team_public_model_name, so resolving them
through get_model_list without team_id returned no deployments and the
gate skipped injection, leaving those streams on tiktoken estimates.
Thread user_api_key_dict.team_id through the gate.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@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 c0de87d. Configure here.

@mateo-berri
mateo-berri merged commit f211341 into litellm_internal_staging Jul 31, 2026
83 checks passed
@mateo-berri
mateo-berri deleted the litellm_stream_usage_by_default branch July 31, 2026 01:12
@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_stream_usage_by_default (c0de87d) with litellm_internal_staging (6e26087)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (79d4962) during the generation of this report, so 2593168 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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