diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 86378b97d2e..7c48474a663 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -200,6 +200,71 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): return params + @staticmethod + def filter_anthropic_output_schema(schema: Dict[str, Any]) -> Dict[str, Any]: + """ + Filter out unsupported fields from JSON schema for Anthropic's output_format API. + + Anthropic's output_format doesn't support certain JSON schema properties: + - maxItems: Not supported for array types + - minItems: Not supported for array types + + This function recursively removes these unsupported fields while preserving + all other valid schema properties. + + Args: + schema: The JSON schema dictionary to filter + + Returns: + A new dictionary with unsupported fields removed + + Related issue: https://github.com/BerriAI/litellm/issues/19444 + """ + if not isinstance(schema, dict): + return schema + + # Fields that Anthropic doesn't support in output_format + unsupported_fields = {"maxItems", "minItems"} + + result = {} + for key, value in schema.items(): + # Skip unsupported fields + if key in unsupported_fields: + continue + + # Recursively filter nested structures + if key == "properties" and isinstance(value, dict): + result[key] = { + k: AnthropicConfig.filter_anthropic_output_schema(v) + for k, v in value.items() + } + elif key == "items" and isinstance(value, dict): + result[key] = AnthropicConfig.filter_anthropic_output_schema(value) + elif key == "$defs" and isinstance(value, dict): + result[key] = { + k: AnthropicConfig.filter_anthropic_output_schema(v) + for k, v in value.items() + } + elif key == "anyOf" and isinstance(value, list): + result[key] = [ + AnthropicConfig.filter_anthropic_output_schema(item) + for item in value + ] + elif key == "allOf" and isinstance(value, list): + result[key] = [ + AnthropicConfig.filter_anthropic_output_schema(item) + for item in value + ] + elif key == "oneOf" and isinstance(value, list): + result[key] = [ + AnthropicConfig.filter_anthropic_output_schema(item) + for item in value + ] + else: + result[key] = value + + return result + def get_json_schema_from_pydantic_object( self, response_format: Union[Any, Dict, None] ) -> Optional[dict]: @@ -636,9 +701,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) if json_schema is None: return None + + # Filter out unsupported fields for Anthropic's output_format API + filtered_schema = self.filter_anthropic_output_schema(json_schema) + return AnthropicOutputSchema( type="json_schema", - schema=json_schema, + schema=filtered_schema, ) def map_response_format_to_anthropic_tool( diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_structured_output.py b/tests/test_litellm/llms/anthropic/test_anthropic_structured_output.py new file mode 100644 index 00000000000..7f6b68881d5 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/test_anthropic_structured_output.py @@ -0,0 +1,169 @@ +""" +Test Anthropic structured output with Pydantic models. + +This test file verifies that Pydantic models with various constraints +are properly converted to Anthropic-compatible JSON schemas. +""" + +import pytest +from pydantic import BaseModel, Field +from typing import List + + +class TestAnthropicStructuredOutput: + """Test Anthropic structured output schema transformations.""" + + def test_max_length_on_list_field_filtered(self): + """ + Test that max_length on List fields is filtered out for Anthropic models. + + Anthropic doesn't support 'maxItems' property for array types in their + output_format.schema, so we need to filter it out. + + Related issue: https://github.com/BerriAI/litellm/issues/19444 + """ + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + # Define a Pydantic model with max_length on a List field + class ResponseModel(BaseModel): + items: List[str] = Field(max_length=5, description="List of items") + name: str = Field(description="Name field") + + config = AnthropicConfig() + + # Get the JSON schema from the Pydantic model + json_schema = config.get_json_schema_from_pydantic_object(ResponseModel) + + # Extract the actual schema + schema = json_schema["json_schema"]["schema"] + + # Verify that maxItems is present in the raw schema (from Pydantic) + assert "maxItems" in schema["properties"]["items"] + + # Now apply the Anthropic output format transformation + response_format = { + "type": "json_schema", + "json_schema": json_schema["json_schema"] + } + + output_format = config.map_response_format_to_anthropic_output_format( + response_format + ) + + # Verify that maxItems is filtered out for Anthropic + assert output_format is not None + assert "schema" in output_format + transformed_schema = output_format["schema"] + + # maxItems should be removed from the items property + assert "maxItems" not in transformed_schema["properties"]["items"] + + # But other properties should remain + assert "type" in transformed_schema["properties"]["items"] + assert transformed_schema["properties"]["items"]["type"] == "array" + assert "description" in transformed_schema["properties"]["items"] + + def test_min_length_on_list_field_filtered(self): + """ + Test that min_length on List fields is filtered out for Anthropic models. + + Anthropic likely doesn't support 'minItems' either. + """ + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + class ResponseModel(BaseModel): + items: List[str] = Field(min_length=2, description="List of items") + + config = AnthropicConfig() + json_schema = config.get_json_schema_from_pydantic_object(ResponseModel) + + response_format = { + "type": "json_schema", + "json_schema": json_schema["json_schema"] + } + + output_format = config.map_response_format_to_anthropic_output_format( + response_format + ) + + assert output_format is not None + transformed_schema = output_format["schema"] + + # minItems should be removed + assert "minItems" not in transformed_schema["properties"]["items"] + + def test_nested_array_constraints_filtered(self): + """ + Test that array constraints are filtered at all nesting levels. + """ + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + class NestedItem(BaseModel): + tags: List[str] = Field(max_length=3) + + class ResponseModel(BaseModel): + items: List[NestedItem] = Field(max_length=5) + + config = AnthropicConfig() + json_schema = config.get_json_schema_from_pydantic_object(ResponseModel) + + response_format = { + "type": "json_schema", + "json_schema": json_schema["json_schema"] + } + + output_format = config.map_response_format_to_anthropic_output_format( + response_format + ) + + assert output_format is not None + transformed_schema = output_format["schema"] + + # Top-level maxItems should be removed + assert "maxItems" not in transformed_schema["properties"]["items"] + + # Nested maxItems should also be removed + if "$defs" in transformed_schema: + nested_item_schema = transformed_schema["$defs"].get("NestedItem", {}) + if "properties" in nested_item_schema and "tags" in nested_item_schema["properties"]: + assert "maxItems" not in nested_item_schema["properties"]["tags"] + + def test_other_constraints_preserved(self): + """ + Test that other valid constraints are preserved. + """ + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + class ResponseModel(BaseModel): + name: str = Field(max_length=100, min_length=1, description="Name") + age: int = Field(ge=0, le=150, description="Age") + items: List[str] = Field(description="Items list") + + config = AnthropicConfig() + json_schema = config.get_json_schema_from_pydantic_object(ResponseModel) + + response_format = { + "type": "json_schema", + "json_schema": json_schema["json_schema"] + } + + output_format = config.map_response_format_to_anthropic_output_format( + response_format + ) + + assert output_format is not None + transformed_schema = output_format["schema"] + + # String constraints should be preserved + name_schema = transformed_schema["properties"]["name"] + assert "maxLength" in name_schema + assert "minLength" in name_schema + assert name_schema["maxLength"] == 100 + assert name_schema["minLength"] == 1 + + # Number constraints should be preserved + age_schema = transformed_schema["properties"]["age"] + assert "minimum" in age_schema + assert "maximum" in age_schema + assert age_schema["minimum"] == 0 + assert age_schema["maximum"] == 150