Skip to content
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 @@ -938,6 +938,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)

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 @@ -949,6 +951,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 @@ -1571,6 +1575,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 @@ -2724,3 +2730,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
21 changes: 21 additions & 0 deletions tests/models/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -3358,3 +3358,24 @@ async def test_openai_chat_instructions_after_system_prompts(allow_model_request
{'role': 'user', 'content': 'Hello'},
]
)


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')
Loading