diff --git a/pydantic_ai_slim/pydantic_ai/_function_schema.py b/pydantic_ai_slim/pydantic_ai/_function_schema.py index 57afe427c7..cdbedb9cf1 100644 --- a/pydantic_ai_slim/pydantic_ai/_function_schema.py +++ b/pydantic_ai_slim/pydantic_ai/_function_schema.py @@ -285,7 +285,6 @@ def _build_schema( td_schema = core_schema.typed_dict_schema( fields, config=core_config, - total=var_kwargs_schema is None, extras_schema=gen_schema.generate_schema(var_kwargs_schema) if var_kwargs_schema else None, ) return td_schema, None diff --git a/pydantic_ai_slim/pydantic_ai/profiles/openai.py b/pydantic_ai_slim/pydantic_ai/profiles/openai.py index f9a2d748f8..103a1e6617 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/openai.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/openai.py @@ -166,11 +166,13 @@ def transform(self, schema: JsonSchema) -> JsonSchema: # noqa C901 schema['required'] = list(schema['properties'].keys()) elif self.strict is None: - if ( - schema.get('additionalProperties') is not False - or 'properties' not in schema - or 'required' not in schema - ): + if schema.get('additionalProperties', None) not in (None, False): + self.is_strict_compatible = False + else: + # additional properties are disallowed by default + schema['additionalProperties'] = False + + if 'properties' not in schema or 'required' not in schema: self.is_strict_compatible = False else: required = schema['required'] diff --git a/pydantic_ai_slim/pydantic_ai/tools.py b/pydantic_ai_slim/pydantic_ai/tools.py index 14b5d12f3a..4df9ad9401 100644 --- a/pydantic_ai_slim/pydantic_ai/tools.py +++ b/pydantic_ai_slim/pydantic_ai/tools.py @@ -133,11 +133,19 @@ async def turn_on_strict_if_openai( class GenerateToolJsonSchema(GenerateJsonSchema): def typed_dict_schema(self, schema: core_schema.TypedDictSchema) -> JsonSchemaValue: - s = super().typed_dict_schema(schema) - total = schema.get('total') - if 'additionalProperties' not in s and (total is True or total is None): - s['additionalProperties'] = False - return s + json_schema = super().typed_dict_schema(schema) + # Workaround for https://github.com/pydantic/pydantic/issues/12123 + if 'additionalProperties' not in json_schema: # pragma: no branch + extra = schema.get('extra_behavior') or schema.get('config', {}).get('extra_fields_behavior') + if extra == 'allow': + extras_schema = schema.get('extras_schema', None) + if extras_schema is not None: + json_schema['additionalProperties'] = self.generate_inner(extras_schema) or True + else: + json_schema['additionalProperties'] = True # pragma: no cover + elif extra == 'forbid': + json_schema['additionalProperties'] = False + return json_schema def _named_required_fields_schema(self, named_required_fields: Sequence[tuple[str, bool, Any]]) -> JsonSchemaValue: # Remove largely-useless property titles diff --git a/tests/models/test_openai.py b/tests/models/test_openai.py index d88ba7a8e9..8df65544b3 100644 --- a/tests/models/test_openai.py +++ b/tests/models/test_openai.py @@ -13,7 +13,7 @@ from dirty_equals import IsListOrTuple from inline_snapshot import snapshot from pydantic import AnyUrl, BaseModel, Discriminator, Field, Tag -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict from pydantic_ai import Agent, ModelHTTPError, ModelRetry, UnexpectedModelBehavior from pydantic_ai.messages import ( @@ -1082,7 +1082,37 @@ class MyDefaultRecursiveDc: field: MyDefaultRecursiveDc | None = None -class MyModel(BaseModel, extra='allow'): +class MyModel(BaseModel): + foo: str + + +class MyDc(BaseModel): + foo: str + + +class MyOptionalDc(BaseModel): + foo: str | None + bar: str + + +class MyExtrasDc(BaseModel, extra='allow'): + foo: str + + +class MyNormalTypedDict(TypedDict): + foo: str + + +class MyOptionalTypedDict(TypedDict): + foo: NotRequired[str] + bar: str + + +class MyPartialTypedDict(TypedDict, total=False): + foo: str + + +class MyExtrasModel(BaseModel, extra='allow'): pass @@ -1106,7 +1136,35 @@ def tool_with_recursion(x: MyRecursiveDc, y: MyDefaultRecursiveDc): return f'{x} {y}' # pragma: no cover -def tool_with_additional_properties(x: MyModel) -> str: +def tool_with_model(x: MyModel) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_dataclass(x: MyDc) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_optional_dataclass(x: MyOptionalDc) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_dataclass_with_extras(x: MyExtrasDc) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_typed_dict(x: MyNormalTypedDict) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_optional_typed_dict(x: MyOptionalTypedDict) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_partial_typed_dict(x: MyPartialTypedDict) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_model_with_extras(x: MyExtrasModel) -> str: return f'{x}' # pragma: no cover @@ -1114,6 +1172,10 @@ def tool_with_kwargs(x: int, **kwargs: Any) -> str: return f'{x} {kwargs}' # pragma: no cover +def tool_with_typed_kwargs(x: int, **kwargs: int) -> str: + return f'{x} {kwargs}' # pragma: no cover + + def tool_with_union(x: int | MyDefaultDc) -> str: return f'{x}' # pragma: no cover @@ -1216,6 +1278,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: } }, 'type': 'object', + 'additionalProperties': False, }, 'MyEnum': {'enum': ['a', 'b'], 'type': 'string'}, 'MyRecursiveDc': { @@ -1225,6 +1288,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: }, 'required': ['field', 'my_enum'], 'type': 'object', + 'additionalProperties': False, }, }, 'additionalProperties': False, @@ -1275,7 +1339,97 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: snapshot(True), ), ( - tool_with_additional_properties, + tool_with_model, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}}, + 'required': ['foo'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_dataclass, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}}, + 'required': ['foo'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_optional_dataclass, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'anyOf': [{'type': 'string'}, {'type': 'null'}]}, 'bar': {'type': 'string'}}, + 'required': ['foo', 'bar'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_dataclass_with_extras, + None, + snapshot( + { + 'additionalProperties': True, + 'properties': {'foo': {'type': 'string'}}, + 'required': ['foo'], + 'type': 'object', + } + ), + snapshot(None), + ), + ( + tool_with_typed_dict, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}}, + 'required': ['foo'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_optional_typed_dict, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}, 'bar': {'type': 'string'}}, + 'required': ['bar'], + 'type': 'object', + } + ), + snapshot(None), + ), + ( + tool_with_partial_typed_dict, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}}, + 'type': 'object', + } + ), + snapshot(None), + ), + ( + tool_with_model_with_extras, None, snapshot( { @@ -1287,7 +1441,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: snapshot(None), ), ( - tool_with_additional_properties, + tool_with_model_with_extras, True, snapshot( { @@ -1304,6 +1458,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: None, snapshot( { + 'additionalProperties': True, 'properties': {'x': {'type': 'integer'}}, 'required': ['x'], 'type': 'object', @@ -1324,6 +1479,19 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: ), snapshot(True), ), + ( + tool_with_typed_kwargs, + None, + snapshot( + { + 'additionalProperties': {'type': 'integer'}, + 'properties': {'x': {'type': 'integer'}}, + 'required': ['x'], + 'type': 'object', + } + ), + snapshot(None), + ), ( tool_with_union, None, @@ -1333,6 +1501,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: 'MyDefaultDc': { 'properties': {'x': {'default': 1, 'type': 'integer'}}, 'type': 'object', + 'additionalProperties': False, } }, 'additionalProperties': False, @@ -1373,6 +1542,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: 'MyDefaultDc': { 'properties': {'x': {'default': 1, 'type': 'integer'}}, 'type': 'object', + 'additionalProperties': False, } }, 'additionalProperties': False, @@ -1413,6 +1583,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: 'MyDefaultDc': { 'properties': {'x': {'default': 1, 'type': 'integer'}}, 'type': 'object', + 'additionalProperties': False, } }, 'additionalProperties': False, diff --git a/tests/test_agent.py b/tests/test_agent.py index 0b37d10410..3cf050728f 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -387,7 +387,6 @@ def test_response_tuple(): name='final_result', description='The final response which ends this conversation', parameters_json_schema={ - 'additionalProperties': False, 'properties': { 'response': { 'maxItems': 2, @@ -637,7 +636,6 @@ class Bar(BaseModel): 'type': 'object', }, }, - 'additionalProperties': False, 'properties': {'response': {'anyOf': [{'$ref': '#/$defs/Foo'}, {'$ref': '#/$defs/Bar'}]}}, 'required': ['response'], 'type': 'object', diff --git a/tests/test_tools.py b/tests/test_tools.py index 560c2b8fbf..eb01352e41 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -387,7 +387,7 @@ def test_docstring_unknown(): { 'name': 'unknown_docstring', 'description': 'Unknown style docstring.', - 'parameters_json_schema': {'properties': {}, 'type': 'object'}, + 'parameters_json_schema': {'additionalProperties': {'type': 'integer'}, 'properties': {}, 'type': 'object'}, 'outer_typed_dict_key': None, 'strict': None, 'kind': 'function', @@ -1031,6 +1031,7 @@ def my_tool(x: Annotated[Union[str, None], WithJsonSchema({'type': 'string'})] = 'name': 'my_tool_1', 'outer_typed_dict_key': None, 'parameters_json_schema': { + 'additionalProperties': True, 'properties': {'x': {'default': None, 'type': 'string'}}, 'type': 'object', }, @@ -1071,7 +1072,6 @@ def get_score(data: Data) -> int: ... # pragma: no branch 'name': 'get_score', 'description': None, 'parameters_json_schema': { - 'additionalProperties': False, 'properties': { 'a': {'description': 'The first parameter', 'type': 'integer'}, 'b': {'description': 'The second parameter', 'type': 'integer'},