Skip to content
1 change: 0 additions & 1 deletion pydantic_ai_slim/pydantic_ai/_function_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions pydantic_ai_slim/pydantic_ai/profiles/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
18 changes: 13 additions & 5 deletions pydantic_ai_slim/pydantic_ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 176 additions & 5 deletions tests/models/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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


Expand All @@ -1106,14 +1136,46 @@ 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


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

Expand Down Expand Up @@ -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': {
Expand All @@ -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,
Expand Down Expand Up @@ -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(
{
Expand All @@ -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(
{
Expand All @@ -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',
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -637,7 +636,6 @@ class Bar(BaseModel):
'type': 'object',
},
},
'additionalProperties': False,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had previously been adding it in ToolGenerateJsonSchema.typed_dict_schema (used for tool args), but now it would only be set when we're preparing tool defs for OpenAI -- which we aren't doing here.

'properties': {'response': {'anyOf': [{'$ref': '#/$defs/Foo'}, {'$ref': '#/$defs/Bar'}]}},
'required': ['response'],
'type': 'object',
Expand Down
4 changes: 2 additions & 2 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool takes **kwargs: int, so this is correct.

'outer_typed_dict_key': None,
'strict': None,
'kind': 'function',
Expand Down Expand Up @@ -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,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool takes **kwargs: Any, so this is correct.

'properties': {'x': {'default': None, 'type': 'string'}},
'type': 'object',
},
Expand Down Expand Up @@ -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'},
Expand Down