diff --git a/src/anthropic/lib/_parse/_transform.py b/src/anthropic/lib/_parse/_transform.py index ce0c83ac9..fe82a4c9e 100644 --- a/src/anthropic/lib/_parse/_transform.py +++ b/src/anthropic/lib/_parse/_transform.py @@ -51,6 +51,27 @@ def get_transformed_string( return schema +def _dynamic_object_keywords_without_properties(json_schema: dict[str, Any]) -> list[str]: + """Return unsupported keywords that are the only way this object can admit keys.""" + keywords: list[str] = [] + + pattern_properties = json_schema.get("patternProperties") + if isinstance(pattern_properties, dict) and pattern_properties: + keywords.append("patternProperties") + + additional_properties = json_schema.get("additionalProperties") + if additional_properties is True or isinstance(additional_properties, dict): + keywords.append("additionalProperties") + + if "propertyNames" in json_schema and additional_properties is not False: + keywords.append("propertyNames") + + if "unevaluatedProperties" in json_schema and json_schema["unevaluatedProperties"] is not False: + keywords.append("unevaluatedProperties") + + return keywords + + def transform_schema( json_schema: type[pydantic.BaseModel] | dict[str, Any], ) -> dict[str, Any]: @@ -126,8 +147,19 @@ def transform_schema( strict_schema["title"] = title if type_ == "object": + properties = json_schema.pop("properties", {}) + if not properties: + unsupported_dynamic_keywords = _dynamic_object_keywords_without_properties(json_schema) + if unsupported_dynamic_keywords: + keywords = ", ".join(unsupported_dynamic_keywords) + raise ValueError( + "Structured output schemas cannot safely transform an object that relies on " + f"{keywords} without explicit properties. Transforming this schema would make " + "the object accept no keys." + ) + strict_schema["properties"] = { - key: transform_schema(prop_schema) for key, prop_schema in json_schema.pop("properties", {}).items() + key: transform_schema(prop_schema) for key, prop_schema in properties.items() } json_schema.pop("additionalProperties", None) strict_schema["additionalProperties"] = False diff --git a/tests/lib/_parse/test_dynamic_object_transform.py b/tests/lib/_parse/test_dynamic_object_transform.py new file mode 100644 index 000000000..edcfb39ab --- /dev/null +++ b/tests/lib/_parse/test_dynamic_object_transform.py @@ -0,0 +1,46 @@ +from typing import Any + +import pytest +from pydantic import BaseModel + +from anthropic.lib._parse._transform import transform_schema + + +def test_additional_properties_map_raises_instead_of_becoming_empty_object(): + schema = { + "type": "object", + "additionalProperties": {"type": "string"}, + } + + with pytest.raises(ValueError, match="additionalProperties"): + transform_schema(schema) + + +def test_additional_properties_true_raises_instead_of_becoming_empty_object(): + schema = { + "type": "object", + "additionalProperties": True, + } + + with pytest.raises(ValueError, match="additionalProperties"): + transform_schema(schema) + + +def test_pydantic_typed_dict_field_raises_instead_of_becoming_empty_object(): + class Model(BaseModel): + values: dict[str, str] + + schema = Model.model_json_schema()["properties"]["values"] + + with pytest.raises(ValueError, match="additionalProperties"): + transform_schema(schema) + + +def test_pydantic_arbitrary_dict_field_raises_instead_of_becoming_empty_object(): + class Model(BaseModel): + values: dict[str, Any] + + schema = Model.model_json_schema()["properties"]["values"] + + with pytest.raises(ValueError, match="additionalProperties"): + transform_schema(schema) diff --git a/tests/lib/_parse/test_transform.py b/tests/lib/_parse/test_transform.py index 7a2799dce..a5af5fb05 100644 --- a/tests/lib/_parse/test_transform.py +++ b/tests/lib/_parse/test_transform.py @@ -229,3 +229,55 @@ def test_original_schema_not_mutated(): transform_schema(original_schema) assert original_schema == original_schema_backup + + +@pytest.mark.parametrize( + ("keyword", "constraint"), + [ + ("patternProperties", {"^S_": {"type": "string"}}), + ("propertyNames", {"pattern": "^S_"}), + ("unevaluatedProperties", {"type": "string"}), + ], +) +def test_dynamic_object_schema_without_properties_raises(keyword: str, constraint: object): + schema = {"type": "object", keyword: constraint} + + with pytest.raises(ValueError, match=keyword): + transform_schema(schema) + + +def test_pattern_properties_with_explicit_properties_keeps_existing_demotion_behavior(): + schema = { + "type": "object", + "properties": {"fixed": {"type": "string"}}, + "required": ["fixed"], + "patternProperties": {"^S_": {"type": "string"}}, + } + + result = transform_schema(schema) + + assert result == { + "type": "object", + "properties": {"fixed": {"type": "string"}}, + "additionalProperties": False, + "required": ["fixed"], + "description": "{patternProperties: {'^S_': {'type': 'string'}}}", + } + + +def test_closed_empty_object_is_not_rejected(): + schema = { + "type": "object", + "additionalProperties": False, + "propertyNames": {"pattern": "^S_"}, + "unevaluatedProperties": False, + } + + result = transform_schema(schema) + + assert result == { + "type": "object", + "properties": {}, + "additionalProperties": False, + "description": "{propertyNames: {'pattern': '^S_'}, unevaluatedProperties: False}", + }