diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 75e5a3549c..db6884d1b0 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -913,6 +913,16 @@ def _map_json_schema(self, o: OutputObjectDefinition) -> chat.completion_create_ return response_format_param def _map_tool_definition(self, f: ToolDefinition) -> chat.ChatCompletionToolParam: + if _has_dict_typed_params(f.parameters_json_schema): + warnings.warn( + f"Tool '{f.name}' has dict-typed parameters that OpenAI's API will silently ignore. " + f'Use a Pydantic BaseModel with explicit fields instead of dict types, ' + f'or switch to a different provider which supports dict types. ' + f'See: https://github.com/pydantic/pydantic-ai/issues/3654', + UserWarning, + stacklevel=4, + ) + tool_param: chat.ChatCompletionToolParam = { 'type': 'function', 'function': { @@ -1527,6 +1537,16 @@ def _get_builtin_tools(self, model_request_parameters: ModelRequestParameters) - return tools def _map_tool_definition(self, f: ToolDefinition) -> responses.FunctionToolParam: + if _has_dict_typed_params(f.parameters_json_schema): + warnings.warn( + f"Tool '{f.name}' has dict-typed parameters that OpenAI's API will silently ignore. " + f'Use a Pydantic BaseModel with explicit fields instead of dict types, ' + 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 { 'name': f.name, 'parameters': f.parameters_json_schema, @@ -2677,3 +2697,38 @@ def _map_mcp_call( provider_name=provider_name, ), ) + + +def _has_dict_typed_params(json_schema: dict[str, Any]) -> bool: + """Detect if a JSON schema contains dict-typed parameters. + + 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 + """ + 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): + return 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] + return True + + # Recursively check nested objects + if 'properties' in prop_schema and _has_dict_typed_params(prop_schema): # type: ignore[reportUnknownArgumentType] + return True + + return False diff --git a/tests/models/test_openai.py b/tests/models/test_openai.py index 74cb3c1414..b9f747cf87 100644 --- a/tests/models/test_openai.py +++ b/tests/models/test_openai.py @@ -3325,3 +3325,53 @@ async def test_openai_reasoning_in_thinking_tags(allow_model_requests: None): """, } ) + + +def test_has_dict_typed_params_simple_dict(): + """Test detection of simple dict[str, str] type.""" + from pydantic_ai.models.openai import _has_dict_typed_params # pyright: ignore[reportPrivateUsage] + + schema = {'properties': {'my_dict': {'type': 'object', 'additionalProperties': {'type': 'string'}}}} + assert _has_dict_typed_params(schema) is True + + +def test_has_dict_typed_params_nested_dict(): + """Test detection of nested dict types.""" + from pydantic_ai.models.openai import _has_dict_typed_params # pyright: ignore[reportPrivateUsage] + + schema = { + 'properties': { + 'nested': {'type': 'object', 'properties': {'inner_dict': {'type': 'object', 'additionalProperties': True}}} + } + } + assert _has_dict_typed_params(schema) is True + + +def test_has_dict_typed_params_array_of_dicts(): + """Test detection of list[dict[str, int]] type.""" + from pydantic_ai.models.openai import _has_dict_typed_params # pyright: ignore[reportPrivateUsage] + + schema = { + 'properties': { + 'dict_list': {'type': 'array', 'items': {'type': 'object', 'additionalProperties': {'type': 'integer'}}} + } + } + assert _has_dict_typed_params(schema) is True + + +def test_has_dict_typed_params_basemodel_no_warning(): + """Test that BaseModel with explicit fields doesn't trigger warning.""" + from pydantic_ai.models.openai import _has_dict_typed_params # pyright: ignore[reportPrivateUsage] + + schema = { + 'properties': { + 'name': {'type': 'string'}, + 'age': {'type': 'integer'}, + 'nested_object': { + 'type': 'object', + 'properties': {'field1': {'type': 'string'}}, + 'additionalProperties': False, + }, + } + } + assert _has_dict_typed_params(schema) is False