From 6fb2a0d11f3e42a087d281db555253cfe84f43fd Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 28 Jan 2026 11:35:18 +0530 Subject: [PATCH] Fix: output_tokens_details.reasoning_tokens None --- litellm/llms/anthropic/chat/transformation.py | 2 +- .../transformation.py | 4 + litellm/types/llms/openai.py | 6 +- .../test_responses_api_bridge_non_stream.py | 380 +++++++++++++++++- 4 files changed, 387 insertions(+), 5 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 82eccee596..5ba901f972 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1369,7 +1369,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): else 0 ) completion_token_details = CompletionTokensDetailsWrapper( - reasoning_tokens=reasoning_tokens if reasoning_tokens > 0 else None, + reasoning_tokens=reasoning_tokens if reasoning_tokens > 0 else 0, text_tokens=completion_tokens - reasoning_tokens if reasoning_tokens > 0 else completion_tokens, ) total_tokens = prompt_tokens + completion_tokens diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 3badbc5057..41abbca755 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1750,6 +1750,8 @@ class LiteLLMCompletionResponsesConfig: and prompt_details.cached_tokens is not None ): input_details_dict["cached_tokens"] = prompt_details.cached_tokens + else: + input_details_dict["cached_tokens"] = 0 if ( hasattr(prompt_details, "text_tokens") @@ -1782,6 +1784,8 @@ class LiteLLMCompletionResponsesConfig: output_details_dict["reasoning_tokens"] = ( completion_details.reasoning_tokens ) + else: + output_details_dict["reasoning_tokens"] = 0 if ( hasattr(completion_details, "text_tokens") diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 9fea1a8b93..b79f098c0c 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1114,7 +1114,7 @@ class ResponsesAPIRequestParams(ResponsesAPIOptionalRequestParams, total=False): class OutputTokensDetails(BaseLiteLLMOpenAIResponseObject): - reasoning_tokens: Optional[int] = None + reasoning_tokens: int text_tokens: Optional[int] = None @@ -1123,7 +1123,7 @@ class OutputTokensDetails(BaseLiteLLMOpenAIResponseObject): class InputTokensDetails(BaseLiteLLMOpenAIResponseObject): audio_tokens: Optional[int] = None - cached_tokens: Optional[int] = None + cached_tokens: int text_tokens: Optional[int] = None model_config = {"extra": "allow"} @@ -1193,7 +1193,7 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject): status: Optional[str] = None text: Optional[Union["ResponseText", Dict[str, Any]]] = None truncation: Optional[Literal["auto", "disabled"]] = None - usage: Optional[Any] = None + usage: Optional[ResponseAPIUsage] = None user: Optional[str] = None store: Optional[bool] = None # Define private attributes using PrivateAttr 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 8f72debfc5..9efe7d3378 100644 --- a/tests/test_litellm/test_responses_api_bridge_non_stream.py +++ b/tests/test_litellm/test_responses_api_bridge_non_stream.py @@ -1,9 +1,32 @@ +import os +import sys +from typing import Optional +from unittest.mock import Mock + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + from litellm.completion_extras.litellm_responses_transformation.handler import ( ResponsesToCompletionBridgeHandler, ) -from litellm.types.llms.openai import ResponsesAPIResponse +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) +from litellm.types.llms.openai import ( + InputTokensDetails, + OutputTokensDetails, + ResponsesAPIResponse, +) +from litellm.types.utils import Choices, Message, ModelResponse, Usage +""" +Test that all providers can transform completion responses to Responses API format +without breaking due to required fields in InputTokensDetails and OutputTokensDetails. +This is a regression test for the change where reasoning_tokens and cached_tokens +were made non-optional (must be int, not Optional[int]). +""" class _CompletedEvent: def __init__(self, response): self.response = response @@ -42,3 +65,358 @@ def test_should_collect_response_from_stream(): assert collected.id == "resp-1" assert collected._hidden_params.get("headers") == {"x-test": "1"} + + +def create_mock_completion_response( + model: str = "gpt-4", + prompt_tokens: int = 10, + completion_tokens: int = 20, + total_tokens: int = 30, + reasoning_tokens: Optional[int] = None, + cached_tokens: Optional[int] = None, + text_tokens: Optional[int] = None, +) -> ModelResponse: + """ + Create a mock ModelResponse (chat completion) with various token details. + + This simulates responses from different providers that may or may not include + reasoning_tokens, cached_tokens, etc. + """ + usage = Usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + ) + + # Add prompt_tokens_details if we have cached_tokens or text_tokens + if cached_tokens is not None or text_tokens is not None: + from litellm.types.utils import PromptTokensDetails + usage.prompt_tokens_details = PromptTokensDetails( + cached_tokens=cached_tokens, + text_tokens=text_tokens, + ) + + # Add completion_tokens_details if we have reasoning_tokens or text_tokens + if reasoning_tokens is not None or text_tokens is not None: + from litellm.types.utils import CompletionTokensDetails + usage.completion_tokens_details = CompletionTokensDetails( + reasoning_tokens=reasoning_tokens, + text_tokens=text_tokens, + ) + + return ModelResponse( + id="chatcmpl-test", + created=1234567890, + model=model, + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="Test response", + role="assistant", + ), + ) + ], + usage=usage, + ) + + +def test_transform_usage_no_token_details(): + """ + Test that transformation works when completion response has NO token details. + + This simulates providers that don't return detailed token breakdowns. + """ + completion_response = create_mock_completion_response( + model="gpt-4", + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + ) + + # Transform to Responses API usage format + responses_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( + completion_response + ) + + # Should succeed without errors + assert responses_usage.input_tokens == 10 + assert responses_usage.output_tokens == 20 + assert responses_usage.total_tokens == 30 + + # Token details should not be present when not provided + assert responses_usage.input_tokens_details is None + assert responses_usage.output_tokens_details is None + + print("✓ Transformation works with no token details") + + +def test_transform_usage_with_cached_tokens_only(): + """ + Test transformation when only cached_tokens is provided (no reasoning_tokens). + + This simulates providers like Anthropic that support prompt caching but not reasoning. + """ + completion_response = create_mock_completion_response( + model="claude-3-opus", + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + cached_tokens=80, # Has cached tokens + reasoning_tokens=None, # No reasoning tokens + ) + + responses_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( + completion_response + ) + + # Should succeed and default reasoning_tokens to 0 + assert responses_usage.input_tokens == 100 + assert responses_usage.output_tokens == 50 + assert responses_usage.total_tokens == 150 + + # Input details should be present with cached_tokens + assert responses_usage.input_tokens_details is not None + assert isinstance(responses_usage.input_tokens_details, InputTokensDetails) + assert responses_usage.input_tokens_details.cached_tokens == 80 + + # Output details should not be present (no reasoning_tokens provided) + assert responses_usage.output_tokens_details is None + + print("✓ Transformation works with cached_tokens only") + + +def test_transform_usage_with_reasoning_tokens_only(): + """ + Test transformation when only reasoning_tokens is provided (no cached_tokens). + + This simulates providers like OpenAI o1 that support reasoning but not caching. + """ + completion_response = create_mock_completion_response( + model="o1-preview", + prompt_tokens=50, + completion_tokens=100, + total_tokens=150, + cached_tokens=None, # No cached tokens + reasoning_tokens=60, # Has reasoning tokens + ) + + responses_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( + completion_response + ) + + # Should succeed and default cached_tokens to 0 + assert responses_usage.input_tokens == 50 + assert responses_usage.output_tokens == 100 + assert responses_usage.total_tokens == 150 + + # Input details should not be present (no cached_tokens provided) + assert responses_usage.input_tokens_details is None + + # Output details should be present with reasoning_tokens + assert responses_usage.output_tokens_details is not None + assert isinstance(responses_usage.output_tokens_details, OutputTokensDetails) + assert responses_usage.output_tokens_details.reasoning_tokens == 60 + + print("✓ Transformation works with reasoning_tokens only") + + +def test_transform_usage_with_both_token_details(): + """ + Test transformation when both cached_tokens and reasoning_tokens are provided. + + This simulates advanced providers that support both features. + """ + completion_response = create_mock_completion_response( + model="gpt-4o", + prompt_tokens=100, + completion_tokens=80, + total_tokens=180, + cached_tokens=50, + reasoning_tokens=30, + text_tokens=50, # Also include text_tokens + ) + + responses_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( + completion_response + ) + + # Should succeed with all details + assert responses_usage.input_tokens == 100 + assert responses_usage.output_tokens == 80 + assert responses_usage.total_tokens == 180 + + # Input details should have cached_tokens + assert responses_usage.input_tokens_details is not None + assert responses_usage.input_tokens_details.cached_tokens == 50 + assert responses_usage.input_tokens_details.text_tokens == 50 + + # Output details should have reasoning_tokens + assert responses_usage.output_tokens_details is not None + assert responses_usage.output_tokens_details.reasoning_tokens == 30 + assert responses_usage.output_tokens_details.text_tokens == 50 + + print("✓ Transformation works with both cached_tokens and reasoning_tokens") + + +def test_transform_usage_with_zero_values(): + """ + Test transformation when token details are explicitly set to 0. + + This ensures 0 values are preserved and not treated as None. + """ + completion_response = create_mock_completion_response( + model="gpt-4", + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + cached_tokens=0, # Explicitly 0 + reasoning_tokens=0, # Explicitly 0 + ) + + responses_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( + completion_response + ) + + # Should preserve 0 values + assert responses_usage.input_tokens_details is not None + assert responses_usage.input_tokens_details.cached_tokens == 0 + + assert responses_usage.output_tokens_details is not None + assert responses_usage.output_tokens_details.reasoning_tokens == 0 + + print("✓ Transformation preserves explicit 0 values") + + +def test_input_tokens_details_requires_cached_tokens(): + """ + Test that InputTokensDetails requires cached_tokens to be an int (not Optional). + + This is the core requirement that was changed. + """ + # Should work with cached_tokens=0 + details1 = InputTokensDetails(cached_tokens=0) + assert details1.cached_tokens == 0 + + # Should work with cached_tokens=100 + details2 = InputTokensDetails(cached_tokens=100) + assert details2.cached_tokens == 100 + + # Should fail without cached_tokens (this is the breaking change) + with pytest.raises((TypeError, ValueError)): + InputTokensDetails() # Missing required field + + print("✓ InputTokensDetails correctly requires cached_tokens") + + +def test_output_tokens_details_requires_reasoning_tokens(): + """ + Test that OutputTokensDetails requires reasoning_tokens to be an int (not Optional). + + This is the core requirement that was changed. + """ + # Should work with reasoning_tokens=0 + details1 = OutputTokensDetails(reasoning_tokens=0) + assert details1.reasoning_tokens == 0 + + # Should work with reasoning_tokens=100 + details2 = OutputTokensDetails(reasoning_tokens=100) + assert details2.reasoning_tokens == 100 + + # Should fail without reasoning_tokens (this is the breaking change) + with pytest.raises((TypeError, ValueError)): + OutputTokensDetails() # Missing required field + + print("✓ OutputTokensDetails correctly requires reasoning_tokens") + + +def test_all_providers_transformation_scenarios(): + """ + Test various provider scenarios to ensure none break after the field requirement change. + + This tests the most common scenarios across different providers: + - OpenAI: may have reasoning_tokens + - Anthropic: may have cached_tokens + - Azure: similar to OpenAI + - Other providers: basic usage only + """ + test_scenarios = [ + { + "name": "Basic provider (no details)", + "model": "gpt-3.5-turbo", + "kwargs": {}, + }, + { + "name": "OpenAI with reasoning", + "model": "o1-preview", + "kwargs": {"reasoning_tokens": 100}, + }, + { + "name": "Anthropic with caching", + "model": "claude-3-opus", + "kwargs": {"cached_tokens": 50}, + }, + { + "name": "OpenAI with caching", + "model": "gpt-4o", + "kwargs": {"cached_tokens": 30}, + }, + { + "name": "Full details (both)", + "model": "gpt-4o", + "kwargs": {"cached_tokens": 40, "reasoning_tokens": 60, "text_tokens": 100}, + }, + { + "name": "Zero values", + "model": "gpt-4", + "kwargs": {"cached_tokens": 0, "reasoning_tokens": 0}, + }, + ] + + for scenario in test_scenarios: + print(f"\nTesting: {scenario['name']}") + + completion_response = create_mock_completion_response( + model=scenario["model"], + **scenario["kwargs"] + ) + + # This should not raise any errors + responses_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( + completion_response + ) + + # Basic assertions + assert responses_usage.input_tokens >= 0 + assert responses_usage.output_tokens >= 0 + assert responses_usage.total_tokens >= 0 + + # If input_tokens_details exists, cached_tokens must be an int + if responses_usage.input_tokens_details is not None: + assert isinstance(responses_usage.input_tokens_details.cached_tokens, int) + + # If output_tokens_details exists, reasoning_tokens must be an int + if responses_usage.output_tokens_details is not None: + assert isinstance(responses_usage.output_tokens_details.reasoning_tokens, int) + + print(f" ✓ {scenario['name']} transformation successful") + + print("\n✓ All provider scenarios work correctly") + + +if __name__ == "__main__": + # Run all tests + test_transform_usage_no_token_details() + test_transform_usage_with_cached_tokens_only() + test_transform_usage_with_reasoning_tokens_only() + test_transform_usage_with_both_token_details() + test_transform_usage_with_zero_values() + test_input_tokens_details_requires_cached_tokens() + test_output_tokens_details_requires_reasoning_tokens() + test_all_providers_transformation_scenarios() + + print("\n" + "="*60) + print("ALL TESTS PASSED!") + print("="*60)