diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 1417c3e324..581d31dd60 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -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) + 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}, @@ -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': { @@ -1601,6 +1605,8 @@ def _get_builtin_tools(self, model_request_parameters: ModelRequestParameters) - return tools def _map_tool_definition(self, f: ToolDefinition) -> responses.FunctionToolParam: + _warn_on_dict_typed_params(f.name, f.parameters_json_schema) + return { 'name': f.name, 'parameters': f.parameters_json_schema, @@ -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 diff --git a/tests/models/test_openai.py b/tests/models/test_openai.py index 993eebcc88..a6817bddb0 100644 --- a/tests/models/test_openai.py +++ b/tests/models/test_openai.py @@ -1548,6 +1548,11 @@ class MyModel(BaseModel): foo: str +class MyModelWDictAttrs(BaseModel): + foo: str + bar: dict[str, str] + + class MyDc(BaseModel): foo: str @@ -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)