Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/agents/models/openai_chatcompletions.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,92 @@
from ..model_settings import ModelSettings


_SCHEMA_MAPPING_KEYWORDS = (
"$defs",
"definitions",
"properties",
"patternProperties",
"dependentSchemas",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Traverse all schema-bearing legacy keywords

When a strict tool uses a Draft 7 schema with a schema-valued dependencies entry or additionalItems, this keyword table never descends into that subschema, so any nested default survives in the outgoing request and the compatibility option still fails against providers that reject defaults. The repository's existing schema walker in tool_output_trimmer.py already recognizes both shapes, including preserving property-name lists under dependencies; align or reuse that vocabulary here.

AGENTS.md reference: AGENTS.md:L92-L94

Useful? React with 👍 / 👎.

)
_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"}
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/agents/models/openai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Forward the compatibility option through MultiProvider

When an application uses the default RunConfig resolver or constructs MultiProvider(openai_client=..., openai_use_responses=False), there is no corresponding openai_strip_tool_schema_defaults argument, so its internal OpenAIProvider always retains this new setting as False. Bare model names are always routed through that internal provider and cannot be redirected through provider_map, leaving affected OpenAI-compatible providers unable to use the compatibility mode without replacing the entire run-level provider; mirror the existing strict-validation and streamed-tool-call-buffering passthroughs in MultiProvider.

AGENTS.md reference: AGENTS.md:L165-L167

Useful? React with 👍 / 👎.

) -> None:
"""Create a new OpenAI provider.

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
114 changes: 114 additions & 0 deletions tests/models/test_tool_schema_default_compat.py
Original file line number Diff line number Diff line change
@@ -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
Loading