From fc9560573b4cfbd393372fce24345e1a375b97fd Mon Sep 17 00:00:00 2001 From: Sameer Kankute <135028480+kankute-sameer@users.noreply.github.com> Date: Thu, 4 Sep 2025 23:06:48 +0530 Subject: [PATCH] [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: --- .../llms/openai/responses/transformation.py | 68 ++++- .../base_responses_api.py | 56 +++- tests/llm_translation/test_openai.py | 2 +- .../test_openai_responses_transformation.py | 245 ++++++++++++++++++ 4 files changed, 367 insertions(+), 4 deletions(-) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index e70cadddaf..392d47f982 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -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, diff --git a/tests/llm_responses_api_testing/base_responses_api.py b/tests/llm_responses_api_testing/base_responses_api.py index c177a76cda..d5d43e175c 100644 --- a/tests/llm_responses_api_testing/base_responses_api.py +++ b/tests/llm_responses_api_testing/base_responses_api.py @@ -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 \ No newline at end of file + + 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") + diff --git a/tests/llm_translation/test_openai.py b/tests/llm_translation/test_openai.py index 619ae338e5..285a406b3f 100644 --- a/tests/llm_translation/test_openai.py +++ b/tests/llm_translation/test_openai.py @@ -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" + diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index 6d46a40f6c..21232161d0 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -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}"