diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index c5e4509126..b4823e1d48 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -54,6 +54,92 @@ from ..model_settings import ModelSettings +_SCHEMA_MAPPING_KEYWORDS = ( + "$defs", + "definitions", + "properties", + "patternProperties", + "dependentSchemas", +) +_SCHEMA_SINGLE_KEYWORDS = ( + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "items", + "contains", + "not", + "if", + "then", + "else", + "contentSchema", +) +_SCHEMA_SEQUENCE_KEYWORDS = ("allOf", "anyOf", "oneOf", "prefixItems") + + +def _strip_json_schema_defaults(schema: dict[str, Any]) -> dict[str, Any]: + """Return a schema copy with `default` removed only from schema nodes.""" + result = {key: value for key, value in schema.items() if key != "default"} + + for keyword in _SCHEMA_MAPPING_KEYWORDS: + mapping = result.get(keyword) + if isinstance(mapping, dict): + result[keyword] = { + name: _strip_json_schema_defaults(value) if isinstance(value, dict) else value + for name, value in mapping.items() + } + + for keyword in _SCHEMA_SINGLE_KEYWORDS: + child = result.get(keyword) + if isinstance(child, dict): + result[keyword] = _strip_json_schema_defaults(child) + elif keyword == "items" and isinstance(child, list): + result[keyword] = [ + _strip_json_schema_defaults(value) if isinstance(value, dict) else value + for value in child + ] + + for keyword in _SCHEMA_SEQUENCE_KEYWORDS: + children = result.get(keyword) + if isinstance(children, list): + result[keyword] = [ + _strip_json_schema_defaults(value) if isinstance(value, dict) else value + for value in children + ] + + return result + + +def _strip_strict_tool_schema_defaults(converted_tools: list[Any]) -> list[Any]: + normalized_tools: list[Any] = [] + for tool in converted_tools: + if not isinstance(tool, dict): + normalized_tools.append(tool) + continue + + function = tool.get("function") + if not isinstance(function, dict) or function.get("strict") is not True: + normalized_tools.append(tool) + continue + + parameters = function.get("parameters") + if not isinstance(parameters, dict): + normalized_tools.append(tool) + continue + + normalized_tools.append( + { + **tool, + "function": { + **function, + "parameters": _strip_json_schema_defaults(parameters), + }, + } + ) + + return normalized_tools + + class OpenAIChatCompletionsModel(Model): _OFFICIAL_OPENAI_SUPPORTED_INPUT_CONTENT_TYPES = frozenset( {"input_text", "input_image", "input_audio", "input_file"} @@ -66,12 +152,14 @@ def __init__( should_replay_reasoning_content: ShouldReplayReasoningContent | None = None, strict_feature_validation: bool = False, buffer_streamed_tool_calls: bool = False, + strip_tool_schema_defaults: bool = False, ) -> None: self.model = model self._client = openai_client self.should_replay_reasoning_content = should_replay_reasoning_content self._strict_feature_validation = strict_feature_validation self._buffer_streamed_tool_calls = buffer_streamed_tool_calls + self._strip_tool_schema_defaults = strip_tool_schema_defaults self._has_warned_unsupported_prompt = False self._has_warned_unsupported_conversation_state = False self._has_warned_unsupported_reasoning_settings = False @@ -640,6 +728,8 @@ async def _fetch_response( converted_tools.append(Converter.convert_handoff_tool(handoff)) converted_tools = _to_dump_compatible(converted_tools) + if self._strip_tool_schema_defaults: + converted_tools = _strip_strict_tool_schema_defaults(converted_tools) tools_param = converted_tools if converted_tools else omit # Chat Completions rejects parallel_tool_calls unless tools are present, so derive it # from the converted list, which also covers handoff-only turns. diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index 642e99df68..1e061d2367 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -58,6 +58,7 @@ def __init__( agent_registration: OpenAIAgentRegistrationConfig | dict[str, Any] | None = None, responses_websocket_options: OpenAIResponsesWebSocketOptions | None = None, buffer_streamed_tool_calls: bool = False, + strip_tool_schema_defaults: bool = False, ) -> None: """Create a new OpenAI provider. @@ -86,6 +87,10 @@ def __init__( function tool-call deltas and emit them to the SDK only after the provider stream finishes. This is useful for OpenAI-compatible providers whose streamed tool-call chunk semantics are not reliable enough for incremental processing. + strip_tool_schema_defaults: Whether Chat Completions models should remove JSON Schema + `default` keywords from strict function-tool parameter schemas before sending the + request. Defaults to False. This opt-in compatibility mode is useful for providers + that reject strict schemas containing non-null defaults. """ if openai_client is not None: if any( @@ -121,6 +126,7 @@ def __init__( self._strict_feature_validation = strict_feature_validation self._responses_websocket_options = responses_websocket_options self._buffer_streamed_tool_calls = buffer_streamed_tool_calls + self._strip_tool_schema_defaults = strip_tool_schema_defaults # Reuse websocket model wrappers so websocket transport can keep a persistent connection # when callers pass model names as strings through a shared provider. @@ -248,6 +254,7 @@ def get_model(self, model_name: str | None) -> Model: openai_client=client, strict_feature_validation=self._strict_feature_validation, buffer_streamed_tool_calls=self._buffer_streamed_tool_calls, + strip_tool_schema_defaults=self._strip_tool_schema_defaults, ) if use_websocket_transport: diff --git a/tests/models/test_tool_schema_default_compat.py b/tests/models/test_tool_schema_default_compat.py new file mode 100644 index 0000000000..02cf11ae85 --- /dev/null +++ b/tests/models/test_tool_schema_default_compat.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import Any, cast + +import httpx2 +import pytest +from openai import AsyncOpenAI +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from pydantic import BaseModel + +from agents import Agent, OpenAIProvider, RunConfig, Runner, function_tool +from agents.models.openai_chatcompletions import _strip_json_schema_defaults + + +class _LookupRequest(BaseModel): + limit: int = 10 + + +@function_tool +def lookup_with_default(request: _LookupRequest) -> str: + return str(request.limit) + + +class _RecordingCompletions: + def __init__(self) -> None: + self.kwargs: dict[str, Any] = {} + + async def create(self, **kwargs: Any) -> ChatCompletion: + self.kwargs = kwargs + return ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[ + Choice( + index=0, + finish_reason="stop", + message=ChatCompletionMessage(role="assistant", content="ok"), + ) + ], + ) + + +class _RecordingClient: + def __init__(self, completions: _RecordingCompletions) -> None: + self.chat = type("_Chat", (), {"completions": completions})() + self.base_url = httpx2.URL("https://example.openai.azure.com/openai/v1/") + + +def _contains_default(value: Any) -> bool: + if isinstance(value, dict): + return "default" in value or any(_contains_default(child) for child in value.values()) + if isinstance(value, list): + return any(_contains_default(child) for child in value) + return False + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("strip_tool_schema_defaults", "expected_request_default"), + [(False, True), (True, False)], +) +async def test_openai_provider_can_strip_strict_tool_schema_defaults( + strip_tool_schema_defaults: bool, + expected_request_default: bool, +) -> None: + completions = _RecordingCompletions() + client = cast(AsyncOpenAI, _RecordingClient(completions)) + provider = OpenAIProvider( + openai_client=client, + use_responses=False, + strip_tool_schema_defaults=strip_tool_schema_defaults, + ) + agent = Agent( + name="test", + model=provider.get_model("gpt-4o"), + tools=[lookup_with_default], + ) + + assert _contains_default(lookup_with_default.params_json_schema) + + await Runner.run( + agent, + "Do not call the tool. Reply with ok.", + run_config=RunConfig(tracing_disabled=True), + ) + + sent_tools = cast(list[dict[str, Any]], completions.kwargs["tools"]) + sent_schema = sent_tools[0]["function"]["parameters"] + assert _contains_default(sent_schema) is expected_request_default + # Compatibility normalization must not mutate the FunctionTool retained by the caller. + assert _contains_default(lookup_with_default.params_json_schema) + + +def test_strip_json_schema_defaults_does_not_rewrite_annotation_payloads() -> None: + schema = { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "default": 10, + "examples": [{"default": "annotation data"}], + } + }, + } + + stripped = _strip_json_schema_defaults(schema) + + assert "default" not in stripped["properties"]["limit"] + assert stripped["properties"]["limit"]["examples"] == [{"default": "annotation data"}] + assert schema["properties"]["limit"]["default"] == 10