mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-19 00:23:47 +00:00
Fix: output_tokens_details.reasoning_tokens None
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user