diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 9938cd7979..c2cfff8068 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -208,29 +208,73 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): 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 + - maxItems/minItems: Not supported for array types + - minimum/maximum: Not supported for numeric types + - minLength/maxLength: Not supported for string types - This function recursively removes these unsupported fields while preserving - all other valid schema properties. + This mirrors the transformation done by the Anthropic Python SDK. + See: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works + + The SDK approach: + 1. Remove unsupported constraints from schema + 2. Add constraint info to description (e.g., "Must be at least 100") + 3. Validate responses against original schema Args: schema: The JSON schema dictionary to filter Returns: - A new dictionary with unsupported fields removed + A new dictionary with unsupported fields removed and descriptions updated - Related issue: https://github.com/BerriAI/litellm/issues/19444 + Related issues: + - https://github.com/BerriAI/litellm/issues/19444 """ if not isinstance(schema, dict): return schema - unsupported_fields = {"maxItems", "minItems"} + # All numeric/string/array constraints not supported by Anthropic + unsupported_fields = { + "maxItems", "minItems", # array constraints + "minimum", "maximum", # numeric constraints + "exclusiveMinimum", "exclusiveMaximum", # numeric constraints + "minLength", "maxLength", # string constraints + } + + # Build description additions from removed constraints + constraint_descriptions: list = [] + constraint_labels = { + "minItems": "minimum number of items: {}", + "maxItems": "maximum number of items: {}", + "minimum": "minimum value: {}", + "maximum": "maximum value: {}", + "exclusiveMinimum": "exclusive minimum value: {}", + "exclusiveMaximum": "exclusive maximum value: {}", + "minLength": "minimum length: {}", + "maxLength": "maximum length: {}", + } + for field in unsupported_fields: + if field in schema: + constraint_descriptions.append( + constraint_labels[field].format(schema[field]) + ) result: Dict[str, Any] = {} + + # Update description with removed constraint info + if constraint_descriptions: + existing_desc = schema.get("description", "") + constraint_note = "Note: " + ", ".join(constraint_descriptions) + "." + if existing_desc: + result["description"] = existing_desc + " " + constraint_note + else: + result["description"] = constraint_note + for key, value in schema.items(): if key in unsupported_fields: continue + if key == "description" and "description" in result: + # Already handled above + continue if key == "properties" and isinstance(value, dict): result[key] = { diff --git a/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py b/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py new file mode 100644 index 0000000000..1bc2213b94 --- /dev/null +++ b/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py @@ -0,0 +1,155 @@ +""" +Tests for Anthropic JSON schema filtering. + +Related to: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works +""" + +import pytest +from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + +class TestFilterAnthropicOutputSchema: + """Test the filter_anthropic_output_schema function.""" + + def test_removes_numeric_constraints(self): + """Test that minimum/maximum are removed from numeric schemas.""" + schema = { + "type": "object", + "properties": { + "age": { + "type": "integer", + "minimum": 0, + "maximum": 150, + "description": "Person's age" + }, + "score": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100 + } + } + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + # minimum/maximum should be removed + assert "minimum" not in result["properties"]["age"] + assert "maximum" not in result["properties"]["age"] + assert "exclusiveMinimum" not in result["properties"]["score"] + assert "exclusiveMaximum" not in result["properties"]["score"] + + # Other fields preserved + assert result["properties"]["age"]["type"] == "integer" + # Description should be updated with removed constraint info + assert "Person's age" in result["properties"]["age"]["description"] + assert "minimum value: 0" in result["properties"]["age"]["description"] + assert "maximum value: 150" in result["properties"]["age"]["description"] + # Score had no description, should get one from constraints + assert "exclusive minimum value: 0" in result["properties"]["score"]["description"] + assert "exclusive maximum value: 100" in result["properties"]["score"]["description"] + + def test_removes_string_constraints(self): + """Test that minLength/maxLength are removed from string schemas.""" + schema = { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "minLength" not in result["properties"]["name"] + assert "maxLength" not in result["properties"]["name"] + assert result["properties"]["name"]["type"] == "string" + # Description should contain constraint info + assert "minimum length: 1" in result["properties"]["name"]["description"] + assert "maximum length: 100" in result["properties"]["name"]["description"] + + def test_removes_array_constraints(self): + """Test that minItems/maxItems are removed from array schemas.""" + schema = { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 10 + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "minItems" not in result + assert "maxItems" not in result + assert result["type"] == "array" + assert result["items"] == {"type": "string"} + # Description should contain constraint info + assert "minimum number of items: 1" in result["description"] + assert "maximum number of items: 10" in result["description"] + + def test_handles_nested_schemas(self): + """Test that nested schemas are also filtered.""" + schema = { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + }, + "minItems": 1 + } + } + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + # Top level array constraint removed + assert "minItems" not in result["properties"]["items"] + + # Nested numeric constraints removed + nested_props = result["properties"]["items"]["items"]["properties"] + assert "minimum" not in nested_props["quantity"] + assert "maximum" not in nested_props["quantity"] + + def test_handles_anyof_oneof_allof(self): + """Test that anyOf/oneOf/allOf schemas are filtered recursively.""" + schema = { + "anyOf": [ + {"type": "integer", "minimum": 0}, + {"type": "string", "minLength": 1} + ] + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "minimum" not in result["anyOf"][0] + assert "minLength" not in result["anyOf"][1] + + def test_preserves_valid_fields(self): + """Test that valid schema fields are preserved.""" + schema = { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive"], + "description": "Current status" + } + }, + "required": ["status"], + "additionalProperties": False + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert result == schema # Should be unchanged