[BUG] Fix response api for reasoning item in input for litellm proxy (#14200)

* fix response api for litellm proxy

* Add test for checking if status is getting removed

* add test in correct file

* remove hardcoded fields

* Make the handling simpler

* fix lint error:
This commit is contained in:
Sameer Kankute
2025-09-04 10:36:48 -07:00
committed by GitHub
parent 5f79e8aac6
commit fc9560573b
4 changed files with 367 additions and 4 deletions
@@ -1,6 +1,15 @@
from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast, get_type_hints
from typing import (
TYPE_CHECKING,
Any,
Dict,
Optional,
Union,
cast,
get_type_hints,
)
import httpx
from openai.types.responses import ResponseReasoningItem
from pydantic import BaseModel
import litellm
@@ -92,12 +101,67 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
# if it's pydantic, convert to dict
if isinstance(item, BaseModel):
validated_input.append(item.model_dump(exclude_none=True))
elif isinstance(item, dict):
# Handle reasoning items specifically to filter out status=None
verbose_logger.debug(f"Handling reasoning item: {item}")
if item.get("type") == "reasoning":
# Type assertion since we know it's a dict at this point
dict_item = cast(Dict[str, Any], item)
filtered_item = self._handle_reasoning_item(dict_item)
else:
# For other dict items, just pass through
filtered_item = cast(Dict[str, Any], item)
validated_input.append(filtered_item)
else:
validated_input.append(item)
return validated_input
return validated_input # type: ignore
# Input is expected to be either str or List, no single BaseModel expected
return input
def _handle_reasoning_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
"""
Handle reasoning items specifically to filter out status=None using OpenAI's model.
Issue: https://github.com/BerriAI/litellm/issues/13484
OpenAI API does not accept ReasoningItem(status=None), so we need to:
1. Check if the item is a reasoning type
2. Create a ResponseReasoningItem object with the item data
3. Convert it back to dict with exclude_none=True to filter None values
"""
verbose_logger.debug(f"Handling reasoning item: {item}")
if item.get("type") == "reasoning":
try:
# Ensure required fields are present for ResponseReasoningItem
item_data = dict(item)
if "id" not in item_data:
item_data["id"] = f"reasoning_{hash(str(item_data))}"
if "summary" not in item_data:
item_data["summary"] = (
item_data.get("reasoning_content", "")[:100] + "..."
if len(item_data.get("reasoning_content", "")) > 100
else item_data.get("reasoning_content", "")
)
# Create ResponseReasoningItem object from the item data
reasoning_item = ResponseReasoningItem(**item_data)
# Convert back to dict with exclude_none=True to exclude None fields
dict_reasoning_item = reasoning_item.model_dump(exclude_none=True)
return dict_reasoning_item
except Exception as e:
verbose_logger.debug(
f"Failed to create ResponseReasoningItem, falling back to manual filtering: {e}"
)
# Fallback: manually filter out known None fields
filtered_item = {
k: v
for k, v in item.items()
if v is not None
or k not in {"status", "content", "encrypted_content"}
}
return filtered_item
return item
def transform_response_api_response(
self,
model: str,
@@ -536,4 +536,58 @@ class BaseResponsesAPITest(ABC):
# Validate final response structure
validate_responses_api_response(final_response, final_chunk=True)
assert final_response.output is not None
assert len(final_response.output) > 0
def test_openai_responses_api_dict_input_filtering(self):
"""
Test that regular dict inputs with status fields are properly filtered
to replicate exclude_unset=True behavior for non-Pydantic objects.
"""
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
# Test input with regular dict objects (like from JSON)
test_input = [
{
"role": "user",
"content": "test"
},
{
"id": "rs_123",
"summary": [{"text": "test", "type": "summary_text"}],
"type": "reasoning",
"content": None, # Should be filtered out
"encrypted_content": None, # Should be filtered out
"status": None # Should be filtered out
},
{
"arguments": "{}",
"call_id": "call_123",
"name": "get_today",
"type": "function_call",
"id": "fc_123",
"status": "completed" # Should be preserved (not a default field)
}
]
config = OpenAIResponsesAPIConfig()
validated_input = config._validate_input_param(test_input)
# Verify the results
assert len(validated_input) == 3
# Check reasoning item (index 1)
reasoning_item = validated_input[1]
assert reasoning_item["type"] == "reasoning"
assert "status" not in reasoning_item, "status field should be filtered out from reasoning item"
assert "content" not in reasoning_item, "content field should be filtered out from reasoning item"
assert "encrypted_content" not in reasoning_item, "encrypted_content field should be filtered out from reasoning item"
assert "id" in reasoning_item, "id field should be preserved"
assert "summary" in reasoning_item, "summary field should be preserved"
# Check function call item (index 2)
function_call_item = validated_input[2]
assert function_call_item["type"] == "function_call"
assert "status" in function_call_item, "status field should be preserved in function call item"
assert function_call_item["status"] == "completed", "status value should be preserved"
print("✅ OpenAI Responses API dict input filtering test passed")
+1 -1
View File
@@ -665,7 +665,6 @@ async def test_openai_gpt5_reasoning():
print("response: ", response)
assert response.choices[0].message.content is not None
@pytest.mark.asyncio
async def test_openai_safety_identifier_parameter():
"""Test that safety_identifier parameter is correctly passed to the OpenAI API."""
@@ -723,3 +722,4 @@ def test_openai_safety_identifier_parameter_sync():
assert "safety_identifier" in request_body
# Verify safety_identifier is correctly sent to the API
assert request_body["safety_identifier"] == "user_code_123456"
@@ -668,3 +668,248 @@ def test_get_supported_openai_params():
assert "stream" in params
assert "background" in params
assert "stream" in params
class TestOpenAIFieldExclusionRegistry:
"""Test suite for the OpenAI Field Exclusion Registry system"""
def setup_method(self):
"""Setup test fixtures"""
from litellm.llms.openai.responses.transformation import (
OpenAIFieldExclusionRegistry,
OpenAIResponsesAPIConfig
)
self.registry = OpenAIFieldExclusionRegistry
self.config = OpenAIResponsesAPIConfig()
def test_registry_initialization(self):
"""Test that the registry is properly initialized with ResponseReasoningItem"""
# Test that we can get excluded fields (should not be empty if ResponseReasoningItem is registered)
all_excluded_fields = self.registry.get_all_excluded_fields()
# The registry should have at least some fields if ResponseReasoningItem was successfully registered
# If OpenAI SDK is not available, this might be empty, which is also valid
assert isinstance(all_excluded_fields, set), "get_all_excluded_fields should return a set"
# If we have the OpenAI SDK available, we should have the expected fields
try:
from openai.types.responses import ResponseReasoningItem
reasoning_fields = self.registry.get_excluded_fields_for_model(ResponseReasoningItem)
expected_fields = {'status', 'content', 'encrypted_content'}
assert expected_fields.issubset(reasoning_fields), f"Expected fields {expected_fields} to be subset of {reasoning_fields}"
except ImportError:
# If OpenAI SDK is not available, that's fine - the registry should handle this gracefully
pytest.skip("OpenAI SDK not available, skipping ResponseReasoningItem specific tests")
def test_register_model_functionality(self):
"""Test that we can register new models to the registry"""
from pydantic import BaseModel
from typing import Optional
# Create a test model with default None fields
class TestResponseModel(BaseModel):
id: str
type: str = "test"
status: Optional[str] = None
content: Optional[str] = None
required_field: str
# Register the test model
self.registry.register_model(TestResponseModel)
# Verify it was registered and fields are detected
excluded_fields = self.registry.get_excluded_fields_for_model(TestResponseModel)
expected_excluded = {'status', 'content'} # Fields with default None
assert expected_excluded.issubset(excluded_fields), f"Expected {expected_excluded} to be in {excluded_fields}"
assert 'id' not in excluded_fields, "Required field 'id' should not be excluded"
assert 'required_field' not in excluded_fields, "Required field 'required_field' should not be excluded"
def test_get_all_excluded_fields(self):
"""Test that get_all_excluded_fields aggregates fields from all registered models"""
all_fields_before = self.registry.get_all_excluded_fields()
# Create and register a test model
from pydantic import BaseModel
from typing import Optional
class AnotherTestModel(BaseModel):
id: str
unique_field: Optional[str] = None
self.registry.register_model(AnotherTestModel)
all_fields_after = self.registry.get_all_excluded_fields()
# The new fields should be included
assert 'unique_field' in all_fields_after, "New model's excluded field should be included"
assert len(all_fields_after) >= len(all_fields_before), "Should have at least as many fields as before"
def test_convenience_registration_method(self):
"""Test the convenience method for registering models"""
from pydantic import BaseModel
from typing import Optional
class ConvenienceTestModel(BaseModel):
id: str
convenience_field: Optional[str] = None
# Use the convenience method
self.config.register_model_for_field_exclusion(ConvenienceTestModel)
# Verify it was registered
excluded_fields = self.registry.get_excluded_fields_for_model(ConvenienceTestModel)
assert 'convenience_field' in excluded_fields, "Field should be excluded after registration"
def test_field_filtering_with_registry(self):
"""Test that the field filtering works correctly with the registry"""
# Test data that matches the structure of ResponseReasoningItem
test_input = [
{
"role": "user",
"content": "test message"
},
{
"id": "reasoning-123",
"type": "reasoning",
"status": None, # Should be filtered out
"content": None, # Should be filtered out
"encrypted_content": None, # Should be filtered out
"summary": [{"text": "This reasoning shows...", "type": "summary_text"}],
"role": "assistant"
},
{
"id": "message-456",
"type": "message",
"status": "completed", # Should be preserved (not None)
"content": "Hello! How can I help?", # Should be preserved (not None)
"role": "assistant"
}
]
# Process the input through the validation
result = self.config._validate_input_param(test_input)
# Verify the structure
assert len(result) == 3, "Should have 3 items"
# Check the reasoning item (index 1)
reasoning_item = result[1]
assert reasoning_item["type"] == "reasoning"
assert reasoning_item["id"] == "reasoning-123"
assert "summary" in reasoning_item, "summary field should be preserved"
assert "role" in reasoning_item, "role field should be preserved"
# These fields should be filtered out if they are in the registry
all_excluded_fields = self.registry.get_all_excluded_fields()
if 'status' in all_excluded_fields:
assert "status" not in reasoning_item, "status field should be filtered out"
if 'content' in all_excluded_fields:
assert "content" not in reasoning_item, "content field should be filtered out"
if 'encrypted_content' in all_excluded_fields:
assert "encrypted_content" not in reasoning_item, "encrypted_content field should be filtered out"
# Check the message item (index 2) - non-None values should be preserved
message_item = result[2]
assert message_item["type"] == "message"
assert message_item["status"] == "completed", "Non-None status should be preserved"
assert message_item["content"] == "Hello! How can I help?", "Non-None content should be preserved"
def test_field_filtering_with_empty_registry(self):
"""Test that filtering works gracefully when no models are registered"""
# Create a fresh registry for this test
from litellm.llms.openai.responses.transformation import OpenAIFieldExclusionRegistry
# Save the current state
original_models = OpenAIFieldExclusionRegistry._MODELS_REQUIRING_EXCLUSION.copy()
try:
# Clear the registry
OpenAIFieldExclusionRegistry._MODELS_REQUIRING_EXCLUSION.clear()
# Test data
test_input = [{
"id": "test-123",
"status": None,
"content": None,
"other_field": "should be preserved"
}]
# Process the input
result = self.config._validate_input_param(test_input)
# With empty registry, nothing should be filtered (all fields preserved)
assert len(result) == 1
item = result[0]
assert "status" in item, "With empty registry, status should be preserved"
assert "content" in item, "With empty registry, content should be preserved"
assert item["other_field"] == "should be preserved"
finally:
# Restore the original state
OpenAIFieldExclusionRegistry._MODELS_REQUIRING_EXCLUSION = original_models
def test_pydantic_v1_v2_compatibility(self):
"""Test that the registry works with both Pydantic v1 and v2"""
from pydantic import BaseModel
from typing import Optional
class CompatibilityTestModel(BaseModel):
id: str
optional_field: Optional[str] = None
required_field: str = "default"
# Register the model
self.registry.register_model(CompatibilityTestModel)
# Get excluded fields
excluded_fields = self.registry.get_excluded_fields_for_model(CompatibilityTestModel)
# Should work regardless of Pydantic version
assert isinstance(excluded_fields, set), "Should return a set"
assert 'optional_field' in excluded_fields, "Field with default None should be excluded"
# Test that the model fields are accessible (works in both v1 and v2)
model_fields = getattr(CompatibilityTestModel, "model_fields", None)
if model_fields is None:
model_fields = getattr(CompatibilityTestModel, "__fields__", {})
assert len(model_fields) > 0, "Should be able to access model fields"
def test_non_registered_model_returns_empty_set(self):
"""Test that non-registered models return empty excluded fields"""
from pydantic import BaseModel
class UnregisteredModel(BaseModel):
id: str
some_field: str = None
# Don't register this model
excluded_fields = self.registry.get_excluded_fields_for_model(UnregisteredModel)
assert excluded_fields == set(), "Non-registered model should return empty set"
@pytest.mark.parametrize("field_value", [None, "", 0, False, []])
def test_only_none_values_are_filtered(self, field_value):
"""Test that only None values are filtered, not other falsy values"""
test_input = [{
"id": "test-123",
"status": field_value,
"content": "actual content",
"other_field": "preserved"
}]
result = self.config._validate_input_param(test_input)
item = result[0]
if field_value is None:
# Only None should be filtered (if status is in the registry)
all_excluded_fields = self.registry.get_all_excluded_fields()
if 'status' in all_excluded_fields:
assert "status" not in item, f"None value should be filtered out"
else:
assert item["status"] is None, f"If not in registry, None should be preserved"
else:
# Other falsy values should be preserved
assert "status" in item, f"Non-None value {field_value} should be preserved"
assert item["status"] == field_value, f"Value should be exactly {field_value}"