fix(openrouter): use provider-reported usage in streaming without stream_options

When providers like OpenRouter send a usage chunk after the finish_reason
chunk, _hidden_params["usage"] was already calculated (with zeros) before
the usage data arrived. The StopIteration handler now recalculates usage
from stream_chunk_builder and updates the shared _hidden_params dict so
the user's copy reflects the real provider-reported token counts.

Fixes #20760
This commit is contained in:
Chesars 2026-02-19 16:07:30 -03:00
parent bac1b6b2e0
commit 27413790e6
2 changed files with 123 additions and 0 deletions

View file

@ -149,6 +149,7 @@ class CustomStreamWrapper:
) # keep track of the returned chunks - used for calculating the input/output tokens for stream options
self.is_function_call = self.check_is_function_call(logging_obj=logging_obj)
self.created: Optional[int] = None
self._last_returned_hidden_params: Optional[dict] = None
def __iter__(self):
return self
@ -1787,6 +1788,7 @@ class CustomStreamWrapper:
if self.sent_last_chunk is True and self.stream_options is None:
usage = calculate_total_usage(chunks=self.chunks)
response._hidden_params["usage"] = usage
self._last_returned_hidden_params = response._hidden_params
# Add MCP metadata to final chunk if present
response = self._add_mcp_metadata_to_final_chunk(response)
# RETURN RESULT
@ -1828,6 +1830,24 @@ class CustomStreamWrapper:
None,
cache_hit,
)
# Update hidden_params with final usage from
# stream_chunk_builder. Some providers (e.g. OpenRouter)
# send usage in a chunk after finish_reason, which arrives
# after _hidden_params["usage"] was initially set. The
# _hidden_params dict is the same object the user received
# (shared by reference), so mutating it here also corrects
# the user's copy.
if (
self.stream_options is None
and complete_streaming_response is not None
and self._last_returned_hidden_params is not None
):
final_usage = getattr(
complete_streaming_response, "usage", None
)
if final_usage is not None:
self._last_returned_hidden_params["usage"] = final_usage
if self.sent_stream_usage is False and self.send_stream_usage is True:
self.sent_stream_usage = True
return response
@ -1951,6 +1971,7 @@ class CustomStreamWrapper:
if self.sent_last_chunk is True and self.stream_options is None:
usage = calculate_total_usage(chunks=self.chunks)
processed_chunk._hidden_params["usage"] = usage
self._last_returned_hidden_params = processed_chunk._hidden_params
# Call post-call streaming deployment hook for final chunk
if self.sent_last_chunk is True:
@ -2017,6 +2038,19 @@ class CustomStreamWrapper:
cache_hit=cache_hit,
)
)
# Update hidden_params with final usage from
# stream_chunk_builder (see sync __next__ for full comment).
if (
self.stream_options is None
and complete_streaming_response is not None
and self._last_returned_hidden_params is not None
):
final_usage = getattr(
complete_streaming_response, "usage", None
)
if final_usage is not None:
self._last_returned_hidden_params["usage"] = final_usage
if self.sent_stream_usage is False and self.send_stream_usage is True:
self.sent_stream_usage = True
return response

View file

@ -1185,3 +1185,92 @@ def test_is_chunk_non_empty_with_valid_tool_calls(
)
is True
)
def test_usage_chunk_after_finish_reason_updates_hidden_params(logging_obj):
"""
Test that provider-reported usage from a post-finish_reason chunk
is surfaced in _hidden_params even when stream_options is NOT set.
Reproduces issue #20760: OpenRouter sends a final chunk with usage data
after the finish_reason chunk. The hidden_params["usage"] on the last
user-visible chunk was being calculated before this usage chunk arrived,
resulting in zeros. The fix recalculates it in the StopIteration handler
after stream_chunk_builder processes all chunks.
"""
# Simulate OpenRouter's actual streaming pattern:
# 1) content chunk
# 2) finish_reason chunk (content="")
# 3) usage chunk (content="", finish_reason=None, usage={...})
chunks = [
ModelResponseStream(
id="gen-abc",
object="chat.completion.chunk",
created=1000000,
model="openrouter/openai/gpt-4o-mini",
choices=[
StreamingChoices(
index=0,
delta=Delta(role="assistant", content="Hello"),
finish_reason=None,
)
],
),
ModelResponseStream(
id="gen-abc",
object="chat.completion.chunk",
created=1000000,
model="openrouter/openai/gpt-4o-mini",
choices=[
StreamingChoices(
index=0,
delta=Delta(content=""),
finish_reason="stop",
)
],
),
ModelResponseStream(
id="gen-abc",
object="chat.completion.chunk",
created=1000000,
model="openrouter/openai/gpt-4o-mini",
choices=[
StreamingChoices(
index=0,
delta=Delta(role="assistant", content=""),
finish_reason=None,
)
],
usage=Usage(
prompt_tokens=20,
completion_tokens=135,
total_tokens=155,
),
),
]
# Create a CustomStreamWrapper with NO stream_options
wrapper = CustomStreamWrapper(
completion_stream=ModelResponseListIterator(model_responses=chunks),
model="openrouter/openai/gpt-4o-mini",
logging_obj=logging_obj,
custom_llm_provider="openrouter",
stream_options=None,
)
# Consume the stream
collected = []
for chunk in wrapper:
collected.append(chunk)
# The last user-visible chunk's _hidden_params["usage"] should
# contain the provider-reported values, not zeros.
last_chunk = collected[-1]
hidden_usage = last_chunk._hidden_params.get("usage")
assert hidden_usage is not None, "Expected usage in _hidden_params"
assert hidden_usage.prompt_tokens == 20, (
f"Expected prompt_tokens=20 from provider, got {hidden_usage.prompt_tokens}"
)
assert hidden_usage.completion_tokens == 135, (
f"Expected completion_tokens=135 from provider, got {hidden_usage.completion_tokens}"
)