Skip to content
Open
55 changes: 55 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,8 @@ def _map_tool_call(t: ToolCallPart) -> ChatCompletionMessageFunctionToolCallPara
)

def _map_json_schema(self, o: OutputObjectDefinition) -> chat.completion_create_params.ResponseFormat:
_warn_on_dict_typed_params(o.name or DEFAULT_OUTPUT_TOOL_NAME, o.json_schema)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This class is also used by OpenRouterModel and tons of other OpenAI-compatible APIs (https://ai.pydantic.dev/models/openai/#openai-compatible-models) so I'm thinking we should do this only when self.system == 'openai'


response_format_param: chat.completion_create_params.ResponseFormatJSONSchema = { # pyright: ignore[reportPrivateImportUsage]
'type': 'json_schema',
'json_schema': {'name': o.name or DEFAULT_OUTPUT_TOOL_NAME, 'schema': o.json_schema},
Expand All @@ -965,6 +967,8 @@ def _map_json_schema(self, o: OutputObjectDefinition) -> chat.completion_create_
return response_format_param

def _map_tool_definition(self, f: ToolDefinition) -> chat.ChatCompletionToolParam:
_warn_on_dict_typed_params(f.name, f.parameters_json_schema)

tool_param: chat.ChatCompletionToolParam = {
'type': 'function',
'function': {
Expand Down Expand Up @@ -1601,6 +1605,8 @@ def _get_builtin_tools(self, model_request_parameters: ModelRequestParameters) -
return tools

def _map_tool_definition(self, f: ToolDefinition) -> responses.FunctionToolParam:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we'll also need to do the check in _map_json_schema

_warn_on_dict_typed_params(f.name, f.parameters_json_schema)

return {
'name': f.name,
'parameters': f.parameters_json_schema,
Expand Down Expand Up @@ -2830,3 +2836,52 @@ def _map_mcp_call(
provider_name=provider_name,
),
)


def _warn_on_dict_typed_params(tool_name: str, json_schema: dict[str, Any]) -> bool:
"""Detect if a JSON schema contains dict-typed parameters and emit a warning if so.

Dict types manifest as objects with additionalProperties that is:
- True (allows any additional properties)
- A schema object (e.g., {'type': 'string'})

These are incompatible with OpenAI's API which silently drops them.

c.f. https://github.com/pydantic/pydantic-ai/issues/3654
"""
has_dict_params = False

properties: dict[str, Any] = json_schema.get('properties', {})
for prop_schema in properties.values():
if isinstance(prop_schema, dict):
# Check for object type with additionalProperties
if prop_schema.get('type') == 'object': # type: ignore[reportUnknownMemberType]
additional_props: Any = prop_schema.get('additionalProperties') # type: ignore[reportUnknownMemberType]
# If additionalProperties is True or a schema object (not False/absent)
if additional_props not in (False, None):
has_dict_params = True

# Check arrays of objects with additionalProperties
if prop_schema.get('type') == 'array': # type: ignore[reportUnknownMemberType]
items: Any = prop_schema.get('items', {}) # type: ignore[reportUnknownMemberType]
if isinstance(items, dict) and items.get('type') == 'object': # type: ignore[reportUnknownMemberType]
if items.get('additionalProperties') not in (False, None): # type: ignore[reportUnknownMemberType]
has_dict_params = True

# Recursively check nested objects
# by default python's warnings module will filter out repeated warnings
# so even with recursion we'll emit a single warning
if 'properties' in prop_schema and _warn_on_dict_typed_params(tool_name, prop_schema): # type: ignore[reportUnknownArgumentType]
has_dict_params = True

if has_dict_params:
warnings.warn(
f"Tool {tool_name!r} has `dict`-typed parameters that OpenAI's API will silently ignore. "
f'Use a Pydantic `BaseModel`, `dataclass`, or `TypedDict` with explicit fields instead, '
f'or switch to a different provider which supports `dict` types. '
f'See: https://github.com/pydantic/pydantic-ai/issues/3654',
UserWarning,
stacklevel=4,
)

return has_dict_params
86 changes: 86 additions & 0 deletions tests/models/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,11 @@ class MyModel(BaseModel):
foo: str


class MyModelWDictAttrs(BaseModel):
foo: str
bar: dict[str, str]


class MyDc(BaseModel):
foo: str

Expand Down Expand Up @@ -3590,6 +3595,87 @@ async def test_openai_chat_instructions_after_system_prompts(allow_model_request
)


async def test_warn_on_dict_typed_params_simple_dict(allow_model_requests: None):
"""Test detection of simple dict[str, str] type tool arguments."""

c = completion_message(
ChatCompletionMessage(content='test response', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)

@agent.tool_plain
async def tool_w_dict_args(data: dict[str, str]):
pass

with pytest.warns(
UserWarning,
match=r"has `dict`-typed parameters that OpenAI's API will silently ignore",
):
await agent.run('test prompt')


async def test_warn_on_dict_typed_params_list_of_dicts(allow_model_requests: None):
"""Test detection of list[dict[str, str]] type tool arguments."""

c = completion_message(
ChatCompletionMessage(content='test response', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)

@agent.tool_plain
async def tool_w_list_of_dicts(data: list[dict[str, int]]):
pass

with pytest.warns(
UserWarning,
match=r"has `dict`-typed parameters that OpenAI's API will silently ignore",
):
await agent.run('test prompt')


async def test_warn_on_dict_typed_params_nested(allow_model_requests: None):
"""Test detection of dict types nested inside a model."""

c = completion_message(
ChatCompletionMessage(content='test response', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)

@agent.tool_plain
async def tool_w_nested_dict(data: MyModelWDictAttrs):
pass

with pytest.warns(
UserWarning,
match=r"has `dict`-typed parameters that OpenAI's API will silently ignore",
):
await agent.run('test prompt')


async def test_no_warn_on_basemodel_without_dict(allow_model_requests: None):
"""Test that BaseModel with explicit fields doesn't trigger warning."""

c = completion_message(
ChatCompletionMessage(content='test response', role='assistant'),
)
mock_client = MockOpenAI.create_mock(c)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)

@agent.tool_plain
async def tool_w_model(data: MyModel):
pass

# Should not emit a warning
await agent.run('test prompt')


def test_openai_chat_audio_default_base64(allow_model_requests: None):
c = completion_message(ChatCompletionMessage(content='success', role='assistant'))
mock_client = MockOpenAI.create_mock(c)
Expand Down
Loading