-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(models): add provider-level strict tool schema default compatibility #4504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an application uses the default AGENTS.md reference: AGENTS.md:L165-L167 Useful? React with 👍 / 👎. |
||
| ) -> 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: | ||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a strict tool uses a Draft 7 schema with a schema-valued
dependenciesentry oradditionalItems, this keyword table never descends into that subschema, so any nesteddefaultsurvives in the outgoing request and the compatibility option still fails against providers that reject defaults. The repository's existing schema walker intool_output_trimmer.pyalready recognizes both shapes, including preserving property-name lists underdependencies; align or reuse that vocabulary here.AGENTS.md reference: AGENTS.md:L92-L94
Useful? React with 👍 / 👎.