From 27413790e6155c97ea188e3063f787e7c8bb4a34 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 19 Feb 2026 16:07:30 -0300 Subject: [PATCH] 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 --- .../litellm_core_utils/streaming_handler.py | 34 +++++++ .../test_streaming_handler.py | 89 +++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 7a6752fbff8..df8095e64d8 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -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 diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index ec2f528a35d..b5922378645 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -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}" + )