From 710ef81a8050a7a9b1d2bd79e3d36b0e364e37bb Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:57:19 -0700 Subject: [PATCH] fix(usage): keep responses usage SDK-parseable and complete streamed reasoning splits An unknown reasoning split now falls back to reasoning_tokens=0 in the chat-to-responses usage translation, since the OpenAI SDK requires output_tokens_details with an int reasoning_tokens, and the streaming chunk builder caps the tokenized reasoning estimate at completion_tokens and fills text_tokens with the remainder --- .../streaming_chunk_builder_utils.py | 7 +++- .../transformation.py | 25 +++++++------- .../test_streaming_chunk_builder_utils.py | 34 +++++++++++++++++++ .../test_litellm_completion_responses.py | 11 +++--- .../test_responses_api_bridge_non_stream.py | 29 +++++++++++++++- 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 036ef3d5557..ee0518c4aec 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -987,7 +987,12 @@ class ChunkProcessor: returned_usage.completion_tokens_details is not None and returned_usage.completion_tokens_details.reasoning_tokens is None ): - returned_usage.completion_tokens_details.reasoning_tokens = reasoning_tokens + capped_reasoning_tokens: Final = min(max(0, reasoning_tokens), returned_usage.completion_tokens) + returned_usage.completion_tokens_details.reasoning_tokens = capped_reasoning_tokens + if returned_usage.completion_tokens_details.text_tokens is None: + returned_usage.completion_tokens_details.text_tokens = ( + returned_usage.completion_tokens - capped_reasoning_tokens + ) if prompt_tokens_details is not None: returned_usage.prompt_tokens_details = prompt_tokens_details diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index b0099edc5dc..64084bfb063 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -2303,18 +2303,19 @@ class LiteLLMCompletionResponsesConfig: # Translate completion_tokens_details to output_tokens_details if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details is not None: completion_details: Final = usage.completion_tokens_details - output_details_dict: Final[dict[str, int]] = {} - if hasattr(completion_details, "reasoning_tokens") and completion_details.reasoning_tokens is not None: - output_details_dict["reasoning_tokens"] = completion_details.reasoning_tokens - - if hasattr(completion_details, "text_tokens") and completion_details.text_tokens is not None: - output_details_dict["text_tokens"] = completion_details.text_tokens - - if hasattr(completion_details, "image_tokens") and completion_details.image_tokens is not None: - output_details_dict["image_tokens"] = completion_details.image_tokens - - if output_details_dict: - response_usage.output_tokens_details = OutputTokensDetails(**output_details_dict) + reasoning_token_count: Final = getattr(completion_details, "reasoning_tokens", None) + optional_output_details: Final[dict[str, int]] = { + field: value + for field, value in ( + ("text_tokens", getattr(completion_details, "text_tokens", None)), + ("image_tokens", getattr(completion_details, "image_tokens", None)), + ) + if value is not None + } + response_usage.output_tokens_details = OutputTokensDetails( + reasoning_tokens=reasoning_token_count if reasoning_token_count is not None else 0, + **optional_output_details, + ) return response_usage diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py index 222afdda3e7..0f21cce476b 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py @@ -1308,3 +1308,37 @@ def test_count_reasoning_tokens_counts_visible_reasoning(): ) assert processor.count_reasoning_tokens(response) > 0 + + +@pytest.mark.parametrize( + "estimated_reasoning_tokens, expected_reasoning_tokens, expected_text_tokens", + [(40, 40, 60), (250, 100, 0)], +) +def test_calculate_usage_fills_unknown_split_from_reasoning_estimate( + estimated_reasoning_tokens, expected_reasoning_tokens, expected_text_tokens +): + from litellm.types.utils import CompletionTokensDetailsWrapper + + chunk = ModelResponseStream( + id="chatcmpl-unknown-split", + model="claude-opus-4-8", + choices=[StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=None, role=None))], + usage=Usage( + prompt_tokens=50, + completion_tokens=100, + total_tokens=150, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=None, text_tokens=None), + ), + ) + processor = ChunkProcessor(chunks=[chunk]) + + usage = processor.calculate_usage( + chunks=[chunk], + model="claude-opus-4-8", + completion_output="10", + reasoning_tokens=estimated_reasoning_tokens, + ) + + assert usage.completion_tokens == 100 + assert usage.completion_tokens_details.reasoning_tokens == expected_reasoning_tokens + assert usage.completion_tokens_details.text_tokens == expected_text_tokens diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 0d4db2a0b11..aae053c2e8e 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2638,10 +2638,10 @@ class TestUsageTransformation: assert response_usage.output_tokens_details.text_tokens == 50 assert response_usage.output_tokens_details.image_tokens == 100 - def test_reasoning_tokens_not_forced_to_zero_when_absent(self): - # Regression: previously the else branch wrote reasoning_tokens=0 even when - # completion_tokens_details had no reasoning (reasoning_tokens=None). That caused - # the proxy to always report reasoning_tokens=0 for non-thinking responses. + def test_reasoning_tokens_fall_back_to_zero_when_absent(self): + # The OpenAI SDK's ResponseUsage requires output_tokens_details.reasoning_tokens + # as an int, so an absent count degrades to 0 on the responses wire instead of + # dropping output_tokens_details and breaking SDK clients. usage = Usage( prompt_tokens=10, completion_tokens=50, @@ -2672,7 +2672,8 @@ class TestUsageTransformation: ) assert response_usage.output_tokens_details is not None - assert response_usage.output_tokens_details.reasoning_tokens is None + assert response_usage.output_tokens_details.reasoning_tokens == 0 + assert response_usage.output_tokens_details.text_tokens == 50 def test_reasoning_tokens_preserved_when_thinking_occurred(self): # Regression: reasoning_tokens must survive the chat->responses translation diff --git a/tests/test_litellm/test_responses_api_bridge_non_stream.py b/tests/test_litellm/test_responses_api_bridge_non_stream.py index c272b151865..08d55ee8290 100644 --- a/tests/test_litellm/test_responses_api_bridge_non_stream.py +++ b/tests/test_litellm/test_responses_api_bridge_non_stream.py @@ -297,7 +297,8 @@ def test_transform_usage_with_zero_values(): cached_tokens=0 is preserved (cache was available; nothing was cached). reasoning_tokens=0 is preserved the same way: an explicit provider-reported - zero passes through, while an absent value (None) is omitted. + zero passes through, while an absent value (None) falls back to 0 because the + Responses API wire contract requires reasoning_tokens as an int. """ completion_response = create_mock_completion_response( model="gpt-4", @@ -321,6 +322,32 @@ def test_transform_usage_with_zero_values(): print("✓ Transformation preserves explicit reasoning_tokens=0 and omits absent values") +def test_transform_usage_unknown_reasoning_split_keeps_output_tokens_details(): + """ + An unknown reasoning split (reasoning_tokens=None, text_tokens=None) must still + emit output_tokens_details with an integer reasoning_tokens: the OpenAI SDK's + ResponseUsage requires the field, so omitting it breaks /v1/responses clients. + """ + from openai.types.responses.response_usage import ( + OutputTokensDetails as OpenAISDKOutputTokensDetails, + ) + + from litellm.types.utils import CompletionTokensDetailsWrapper + + usage = Usage( + prompt_tokens=100, + completion_tokens=500, + total_tokens=600, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=None, text_tokens=None), + ) + + responses_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(usage) + + assert responses_usage.output_tokens_details is not None + assert responses_usage.output_tokens_details.reasoning_tokens == 0 + OpenAISDKOutputTokensDetails.model_validate(responses_usage.output_tokens_details.model_dump(exclude_none=True)) + + def test_input_tokens_details_requires_cached_tokens(): """ Test that InputTokensDetails has cached_tokens as an int with default value 0.