Skip to content

[Fix] OpenRouter Streaming Usage Cost - #16162

Open
dhruvyad wants to merge 4 commits into
BerriAI:mainfrom
dhruvyad:fix_openrouter_streaming_usage
Open

[Fix] OpenRouter Streaming Usage Cost#16162
dhruvyad wants to merge 4 commits into
BerriAI:mainfrom
dhruvyad:fix_openrouter_streaming_usage

Conversation

@dhruvyad

@dhruvyad dhruvyad commented Nov 1, 2025

Copy link
Copy Markdown
Contributor

Title

Fix usage cost calculation for streaming models via OpenRouter.

Relevant issues

Extension of #13653

Pre-Submission checklist

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

  • I have Added testing in the tests/litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • I have added a screenshot of my new test passing locally
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
image

Type

🐛 Bug Fix
✅ Test

Changes

Fix usage cost tracking for streaming, which is currently broken for OpenRouter models.

@vercel

vercel Bot commented Nov 1, 2025

Copy link
Copy Markdown

@dhruvyad is attempting to deploy a commit to the CLERKIEAI Team on Vercel.

A member of the Team first needs to authorize it.

@dhruvyad
dhruvyad marked this pull request as draft November 1, 2025 20:27
@dhruvyad
dhruvyad force-pushed the fix_openrouter_streaming_usage branch from d1f460f to 96d2c46 Compare November 1, 2025 20:32
@dhruvyad
dhruvyad marked this pull request as ready for review November 1, 2025 20:54
@paulchaum

Copy link
Copy Markdown

Is this PR still active? I'd love to have this feature

@dhruvyad

dhruvyad commented Dec 6, 2025

Copy link
Copy Markdown
Contributor Author

Is this PR still active? I'd love to have this feature

I'm using a forked version cause this PR didn't get reviewed. Happy to rebase and address comments if there's interest in merging this.

@mparsam

mparsam commented Feb 12, 2026

Copy link
Copy Markdown

As of today really look forward to have this fix
the support for non-streaming is done here
Streaming is the real need here. only me, I know at least two teams completely blocked by this problem.
@dhruvyad @krrishdholakia

or if anyone has trick to solve this I will be thankful

@arbv

arbv commented Mar 30, 2026

Copy link
Copy Markdown

Pretty please review this.

@paulchaum

Copy link
Copy Markdown

As of today really look forward to have this fix the support for non-streaming is done here Streaming is the real need here. only me, I know at least two teams completely blocked by this problem. @dhruvyad @krrishdholakia

or if anyone has trick to solve this I will be thankful

I'm also waiting for streaming support.

In the meantime, the only workaround I've found to solve this problem is to manually define the list of models and their costs in config.yaml. You could potentially write a script that periodically fetches the model costs using curl https://openrouter.ai/api/v1/models and updates config.yaml (see OpenRouter doc).

Without this, the provider-reported cost (e.g. from OpenRouter) was
available on usage.cost but never reached litellm's cost calculator,
which reads from _hidden_params["additional_headers"]. Also cleans up
setattr usage in tests since Usage already has a cost field.
@dhruvyad
dhruvyad force-pushed the fix_openrouter_streaming_usage branch from 4e7457e to 387a94e Compare March 31, 2026 09:36
@vercel

vercel Bot commented Mar 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 31, 2026 10:01am

Request Review

@codspeed-hq

codspeed-hq Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing dhruvyad:fix_openrouter_streaming_usage (d1e9c52) with main (08be1e5)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes OpenRouter streaming cost tracking by handling the provider-specific pattern where a usage chunk (containing token counts and cost) arrives after the finish_reason chunk instead of alongside it.

Key changes:

  • return_processed_chunk_logic no longer raises StopIteration when a post-finish usage chunk arrives; it returns the chunk so it can be appended to self.chunks and later picked up by stream_chunk_builder.
  • cost is threaded through UsagePerChunk, _usage_chunk_calculation_helper, calculate_usage_per_chunk, calculate_usage, and calculate_total_usage.
  • A new _propagate_usage_cost_to_hidden_params static method writes usage.cost into _hidden_params[\"additional_headers\"][\"llm_provider-x-litellm-response-cost\"], the key the cost calculator reads. Both sync and async StopIteration handlers now call this helper, resolving the previous duplication concern.
  • ModelResponseStream.__init__ explicitly reassigns self.usage after super().__init__() to prevent Pydantic from silently dropping the Usage object (and its cost field) during construction.

Remaining concerns:

  • calculate_total_usage tracks cost via latest_usage_chunk (last-wins on the whole usage object), while calculate_usage_per_chunk uses a safer last-non-None-wins approach for cost specifically. If a provider sends a trailing chunk with cost=None after a cost-bearing chunk, calculate_total_usage would silently discard the cost.
  • _propagate_usage_cost_to_hidden_params assumes _hidden_params[\"additional_headers\"] is a dict when present; a None value would raise TypeError.
  • The setattr-then-model_dump() ordering in ChunkProcessor.calculate_usage works today but is fragile to reordering.

Confidence Score: 4/5

Safe to merge after addressing the calculate_total_usage cost-loss edge case; the core fix is correct and well-tested.

One P1 finding: calculate_total_usage can lose a previously-seen cost if any later usage chunk carries cost=None, because latest_usage_chunk is overwritten unconditionally. This is not triggered by the current OpenRouter flow (cost chunk is always last), but is a latent bug. The two P2 findings are minor defensive/style issues. The core fix is logically correct and backed by new unit and async integration tests.

litellm/litellm_core_utils/streaming_handler.py — specifically calculate_total_usage and _propagate_usage_cost_to_hidden_params

Important Files Changed

Filename Overview
litellm/litellm_core_utils/streaming_handler.py Core fix: instead of raising StopIteration on post-finish usage chunks, return them so cost data is accumulated in self.chunks; adds _propagate_usage_cost_to_hidden_params helper; extends calculate_total_usage to carry cost from the latest usage chunk.
litellm/litellm_core_utils/streaming_chunk_builder_utils.py Threads cost through UsagePerChunk and calculate_usage; adds hasattr-first usage access for ModelResponseStream objects; setattr cost before recreating Usage via model_dump().
litellm/types/litellm_core_utils/streaming_chunk_builder_utils.py Adds cost: Optional[float] to UsagePerChunk TypedDict to carry provider-reported cost through the pipeline.
litellm/types/utils.py Workaround: explicitly re-assigns self.usage after super().init() to prevent Pydantic from silently dropping the Usage object (including its cost field) during ModelResponseStream initialisation.
tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py Adds test_cost_field_in_usage_chunks covering ChunkProcessor.calculate_usage cost extraction; remaining changes are formatting/style only.
tests/test_litellm/litellm_core_utils/test_streaming_handler.py Adds three new tests: calculate_total_usage cost unit test, async end-to-end cost propagation test, and a lighter hidden-params propagation test; remaining changes are formatting only.

Sequence Diagram

sequenceDiagram
    participant OR as OpenRouter Stream
    participant CSW as CustomStreamWrapper
    participant CC as chunk_creator
    participant RPC as return_processed_chunk_logic
    participant SCB as stream_chunk_builder
    participant CCC as ChunkProcessor.calculate_usage

    OR->>CSW: chunk1 (content delta)
    CSW->>CC: chunk1
    CC->>RPC: model_response (no usage)
    RPC-->>CC: return model_response
    CC-->>CSW: model_response
    CSW->>CSW: chunks.append(chunk1)
    CSW-->>OR: yield chunk1

    OR->>CSW: chunk2 (finish_reason=stop)
    CSW->>CC: chunk2
    CC->>RPC: model_response (finish_reason)
    RPC->>RPC: sent_last_chunk = True
    RPC-->>CC: return model_response
    CC-->>CSW: model_response
    CSW->>CSW: chunks.append(chunk2)
    CSW-->>OR: yield chunk2

    OR->>CSW: chunk3 (usage with cost=0.00025)
    CSW->>CC: chunk3
    CC->>CC: model_response.usage = chunk3.usage
    CC->>RPC: model_response (sent_last_chunk=True, has usage)
    Note over RPC: NEW: return instead of StopIteration
    RPC-->>CC: return model_response
    CC-->>CSW: model_response with usage+cost
    CSW->>CSW: chunks.append(copy with usage+cost)
    CSW->>CSW: strip usage, is_empty=True, continue

    OR->>CSW: StopIteration
    CSW->>SCB: stream_chunk_builder(chunks)
    SCB->>CCC: calculate_usage(chunks)
    CCC->>CCC: extract cost=0.00025 from chunk3
    CCC-->>SCB: Usage(tokens, cost=0.00025)
    SCB-->>CSW: complete_response
    CSW->>CSW: _propagate_usage_cost_to_hidden_params
    Note over CSW: additional_headers[llm_provider-x-litellm-response-cost]=0.00025
    CSW->>CSW: _last_returned_hidden_params[usage] = final_usage
    CSW-->>OR: raise StopIteration
Loading

Reviews (2): Last reviewed commit: "address review feedback: remove provider..." | Re-trigger Greptile

Comment on lines +1148 to +1152
if self.custom_llm_provider != "openrouter":
raise StopIteration
else:
# OpenRouter: continue processing - usage will come in later chunks
pass

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.

P1 Provider-specific code outside llms/ directory

The hardcoded "openrouter" check in the core streaming_handler.py violates the project's architecture rule to keep provider-specific logic inside litellm/llms/. This check adds conditional branching that will grow over time as more providers share this late-usage pattern.

The more general fix already in this PR — having return_processed_chunk_logic return the usage chunk instead of raising StopIteration — works for ALL providers by design. This provider guard should be removed; if the chunk carries no content and generic_chunk_has_all_required_fields already accepted it, silently passing is the correct behavior for any provider, not just OpenRouter.

Suggested change
if self.custom_llm_provider != "openrouter":
raise StopIteration
else:
# OpenRouter: continue processing - usage will come in later chunks
pass
if not _chunk_has_content and (
not isinstance(chunk, dict)
or "provider_specific_fields" not in chunk
):
pass # no content / special fields – continue to build the response_obj

Rule Used: What: Avoid writing provider-specific code outside... (source)

Comment on lines +1027 to +1035
# Don't raise StopIteration here - some providers (like OpenRouter)
# send usage/cost data in chunks after the finish_reason chunk
if (
hasattr(model_response, "usage")
and model_response.usage is not None
):
self.chunks.append(model_response)
raise StopIteration
return model_response
return

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.

P1 Usage chunk double-appended to self.chunks

return_processed_chunk_logic now appends the usage chunk to self.chunks (line 1033) and then returns it (line 1034). Control passes back to __next__, which appends the same response again at line 1884 (self.chunks.append(response)).

Old code raised StopIteration here, so __next__'s append was never reached. With the new return-path that append is still executed, leaving every post-finish usage chunk recorded twice in self.chunks.

Currently calculate_total_usage and stream_chunk_builder both use a "last wins" strategy for token counts, so the duplicated chunk doesn't produce wrong numbers today. But self.chunks grows unexpectedly, and any future caller that sums or counts chunks would double-count usage.

The simplest fix is to not append inside return_processed_chunk_logic and let __next__'s existing append be the single source of truth:

Suggested change
# Don't raise StopIteration here - some providers (like OpenRouter)
# send usage/cost data in chunks after the finish_reason chunk
if (
hasattr(model_response, "usage")
and model_response.usage is not None
):
self.chunks.append(model_response)
raise StopIteration
return model_response
return
# Don't raise StopIteration here - some providers (like OpenRouter)
# send usage/cost data in chunks after the finish_reason chunk
if (
hasattr(model_response, "usage")
and model_response.usage is not None
):
return model_response
return

Comment on lines +2428 to +2433
if (
latest_usage_chunk
and hasattr(latest_usage_chunk, "cost")
and latest_usage_chunk.cost is not None
):
return latest_usage_chunk

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.

P2 Early-return may discard accumulated token counts

When the most recent usage chunk carries a cost, the function returns that chunk object directly, bypassing the Usage(...) construction below where prompt_tokens and completion_tokens from the local accumulators are used. If a provider ever emits cost in a final chunk that has zero/missing token counts, the returned object will show incorrect totals. Consider merging the cost value into a freshly-constructed Usage built from the accumulated variables to be consistent with the non-cost path.

Comment on lines +1933 to +1955

response = self.model_response_creator()
if complete_streaming_response is not None:
# Propagate provider-reported cost (e.g. OpenRouter)
# to _hidden_params so the cost calculator picks it up
_final_usage = getattr(complete_streaming_response, "usage", None)
if (
_final_usage is not None
and hasattr(_final_usage, "cost")
and _final_usage.cost is not None
):
if (
"additional_headers"
not in complete_streaming_response._hidden_params
):
complete_streaming_response._hidden_params[
"additional_headers"
] = {}
complete_streaming_response._hidden_params[
"additional_headers"
]["llm_provider-x-litellm-response-cost"] = float(
_final_usage.cost
)

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.

P2 Duplicated cost-propagation block

The ~20-line block that reads the cost from _final_usage and writes it into the response hidden params is copy-pasted verbatim into both the sync except StopIteration handler (~line 1936) and the async except (StopAsyncIteration, StopIteration) handler (~line 2183). Any future change to the header name or logic must be applied in two places. Consider extracting it to a small private helper method.

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!

Comment on lines +1298 to +1343
model="openrouter/claude",
choices=[
StreamingChoices(finish_reason=None, index=0, delta=Delta(content=""))
],
usage=Usage(
completion_tokens=5, prompt_tokens=10, total_tokens=15, cost=0.00025
),
)

# Build the complete response as stream_chunk_builder does
complete_response = litellm.stream_chunk_builder(
chunks=[chunk1, chunk2, chunk3],
messages=[{"role": "user", "content": "test"}],
)

assert complete_response is not None
assert hasattr(complete_response.usage, "cost")
assert complete_response.usage.cost == 0.00025

# Simulate the propagation logic from streaming_handler
_final_usage = getattr(complete_response, "usage", None)
if (
_final_usage is not None
and hasattr(_final_usage, "cost")
and _final_usage.cost is not None
):
if "additional_headers" not in complete_response._hidden_params:
complete_response._hidden_params["additional_headers"] = {}
complete_response._hidden_params["additional_headers"][
"llm_provider-x-litellm-response-cost"
] = float(_final_usage.cost)

assert "additional_headers" in complete_response._hidden_params
assert (
complete_response._hidden_params["additional_headers"][
"llm_provider-x-litellm-response-cost"
]
== 0.00025
)

# Verify the cost calculator would pick this up
from litellm.cost_calculator import get_response_cost_from_hidden_params

provider_cost = get_response_cost_from_hidden_params(
complete_response._hidden_params
)

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.

P2 Test duplicates production logic instead of exercising it

test_openrouter_streaming_cost_propagates_to_hidden_params manually copies the propagation block from streaming_handler.py into the test body and then asserts that the code it just executed works. This means the test will pass even if the production path is broken — it only verifies the test's own inline copy of the logic.

To make this a real regression detector, the test should drive the complete response through CustomStreamWrapper (as test_openrouter_streaming_cost_after_finish_reason does) and then inspect the hidden params of the wrapper's final state, rather than simulating the propagation inline.

Rule Used: # Code Review Rule: Mock Test Integrity

What:... (source)

…ppend, DRY cost propagation

- Remove hardcoded openrouter check from core handler; the general
  return-instead-of-raise in return_processed_chunk_logic already
  handles post-finish-reason usage chunks for any provider.
- Remove self.chunks.append in return_processed_chunk_logic to avoid
  double-appending (caller already appends).
- Build proper Usage in calculate_total_usage instead of returning
  raw chunk early — preserves cost while ensuring token fields are
  always reconstructed.
- Extract cost propagation into _propagate_usage_cost_to_hidden_params
  static method, used by both sync and async paths.
- Test calls the real method instead of copy-pasting production logic.
Comment on lines +2412 to +2418
if (
latest_usage_chunk
and hasattr(latest_usage_chunk, "cost")
and latest_usage_chunk.cost is not None
):
returned_usage_chunk.cost = latest_usage_chunk.cost

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.

P1 calculate_total_usage loses cost if any later chunk has cost=None

latest_usage_chunk is overwritten on every chunk that carries non-None usage, not just on the chunk that carries cost. If a provider sends a final summary chunk after the cost-bearing chunk (e.g. a token-count-only reconciliation chunk with cost=None), latest_usage_chunk would end up pointing at the later chunk and the cost check at line 2412 would return False, silently discarding the cost.

ChunkProcessor.calculate_usage_per_chunk uses the safer "last non-None wins" pattern:

if usage_chunk_dict["cost"] is not None:
    cost = usage_chunk_dict["cost"]  # only overwrites when there is an actual value

A safer alternative that mirrors calculate_usage_per_chunk:

# Track cost separately so a later cost=None chunk can't erase an earlier cost
latest_cost: Optional[float] = None
for chunk in chunks:
    if "usage" in chunk and chunk["usage"] is not None:
        usage = chunk["usage"]
        latest_usage_chunk = usage
        if "prompt_tokens" in usage:
            prompt_tokens = usage.get("prompt_tokens", 0) or 0
        if "completion_tokens" in usage:
            completion_tokens = usage.get("completion_tokens", 0) or 0
        if hasattr(usage, "cost") and usage.cost is not None:
            latest_cost = usage.cost

if latest_cost is not None:
    returned_usage_chunk.cost = latest_cost

In the current OpenRouter flow the cost chunk is always the last one, so this doesn't manifest today — but it's a latent bug for any provider that sends a trailing reconciliation chunk.

@dhruvyad

Copy link
Copy Markdown
Contributor Author

@paulchaum @arbv @mparsam @krrish-berri-2 @ishaan-berri @ishaan-jaff Rebased and tested this PR given the demand. Let me know if I can help with anything else.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.

@github-actions github-actions Bot added the stale label Jun 30, 2026
@arbv

arbv commented Jun 30, 2026

Copy link
Copy Markdown

not stale

@blakeaa827

Copy link
Copy Markdown

Reviewed and tested this locally (checked out this branch, Python 3.9) — it correctly fixes the core of #11626 (which is still open, auto-closed as stale).

Mechanism looks right: _propagate_usage_cost_to_hidden_params writes usage.cost into _hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] — the same key the non-streaming path (OpenrouterConfig.transform_response) uses, and which get_response_cost_from_hidden_params short-circuits on before the static cost map. So streamed OpenRouter calls now report the real provider cost instead of falling back to the map (which is $0 for models that aren't in it).

Tests pass locally:

tests/test_litellm/litellm_core_utils/test_streaming_handler.py
tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py
tests/test_litellm/llms/openrouter/
→ 166 passed

One gap: this threads cost but not cost_details / is_byok, and #11626's title explicitly names is_byok. It's a small addition right after returned_usage_chunk.cost = latest_usage_chunk.cost in calculate_total_usage:

    # Preserve OpenRouter-style provider usage metadata (issue #11626).
    if getattr(latest_usage_chunk, "cost_details", None) is not None:
        returned_usage_chunk.cost_details = latest_usage_chunk.cost_details
    if getattr(latest_usage_chunk, "is_byok", None) is not None:
        returned_usage_chunk.is_byok = latest_usage_chunk.is_byok

I have this plus a test passing locally (167 total) and am happy to send it as a follow-up once this merges, or you're welcome to fold it in here. Either way, solid fix — would be great to see it land. It resolves #11626.

@krrish-berri-2

krrish-berri-2 commented Jul 4, 2026 via email

Copy link
Copy Markdown
Contributor

@mateo-berri

Copy link
Copy Markdown
Contributor

I'll make a copy branch for review. I see a bunch of folks testing via unit tests. @dhruvyad do you mind uploading screenshots/video or an ordered list of command+command outputs ran showing it works e2e?

pull Bot pushed a commit to TKaxv-7S/litellm that referenced this pull request Jul 16, 2026
Port of BerriAI#16162 by @dhruvyad onto litellm_internal_staging.

OpenRouter sends a usage chunk (including a provider-reported cost field)
after the finish_reason chunk. Previously the stream handler raised
StopIteration on the first post-finish chunk, so that usage/cost never
reached the assembled response and cost tracking fell back to token-based
estimates.

Carry usage.cost through chunk accumulation, preserve stripped usage in
_hidden_params, and propagate the provider cost into
_hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"]
so the cost calculator uses it.
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.

7 participants