openai / openai/openai-agents-python
ModelSettings.extra_args with a provider-native value aborts every Chat Completions model call with PydanticSerializationError
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 29.6k
- Forks
- 4.8k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 123
Description
Please read this first
- Have you read the docs? Yes — Models,
ModelSettings.extra_args, Tracing. - Have you searched for related issues? Yes. #4983 is also a JSON-serialization defect but on the opposite side: it drops a span export batch while the run succeeds. This one aborts the model call itself, in
ModelSettings, and happens with tracing disabled. Nothing open or closed coversextra_argsserialization.
Describe the bug
Setting a provider-native value in ModelSettings.extra_args makes every Chat Completions-family model call fail with PydanticSerializationError before any HTTP request is issued. Disabling tracing does not avoid it.
extra_args is documented as "Arbitrary keyword arguments to pass to the model API call. These will be passed directly to the underlying model provider's API" and typed dict[str, Any]. The Chat Completions adapter does exactly that: create_kwargs.update(model_settings.extra_args or {}) straight into chat.completions.create(**create_kwargs) (openai_chatcompletions.py#L735). A Timeout object is a declared value for that parameter — openai types it timeout: float | httpx2.Timeout | None | NotGiven — so extra_args={"timeout": httpx.Timeout(30.0)} is the documented way to set a per-request timeout for one agent.
The failure is in the tracing helper, not in the request path. to_traceable_dict() exists to keep provider request extras out of traces, but it serializes the whole settings object first and filters afterwards, so the fields it is about to discard still have to be JSON serializable:
https://github.com/openai/openai-agents-python/blob/fbf59a40/src/agents/model_settings.py#L291-L297
def to_json_dict(self) -> dict[str, Any]:
return cast(dict[str, Any], TypeAdapter(ModelSettings).dump_python(self, mode="json"))
def to_traceable_dict(self) -> dict[str, Any]:
"""Serialize settings for tracing without provider-specific request extras."""
payload = self.to_json_dict() # serializes extra_args / extra_body / extra_query too
return {key: payload[key] for key in _TRACEABLE_MODEL_SETTING_FIELDS if key in payload}
_TRACEABLE_MODEL_SETTING_FIELDS (model_settings.py#L64-L85) deliberately omits extra_args, extra_body, extra_query, and extra_headers.
Tracing cannot be switched off to avoid it, because the argument is evaluated eagerly at the span call site, before disabled=tracing.is_disabled() can suppress anything:
openai_chatcompletions.py#L238and#L450(streaming)litellm_model.py#L225and#L398any_llm_model.py#L586and#L722
OpenAIResponsesModel does not build a traced model config from ModelSettings, so the same setting works there. The asymmetry is what makes this look unintended rather than a documented restriction.
Debug information
- Agents SDK version:
mainatfbf59a40, andv0.22.2(the function is byte-identical in the release tag) - Related library versions:
openai3.0.0,pydantic2.12.3,httpx22.9.1 - Python version: 3.13.14
- Operating system: macOS 26 (Darwin 25.6.0, arm64)
- Model and model provider: any Chat Completions-family adapter —
OpenAIChatCompletionsModel,LitellmModel,AnyLLMModel. The repro uses a controlled httpx transport, so no provider request is needed to observe it. - Does the issue reproduce with the latest Agents SDK release? Yes,
v0.22.2. - Does the issue occur consistently or intermittently? Consistently, on every call, for any
extra_argsvalue Pydantic cannot dump in JSON mode.
Traceback (most recent call last):
...
File "src/agents/run_internal/model_retry.py", line 296, in _await_model_attempt
return await awaitable
File "src/agents/models/openai_chatcompletions.py", line 238, in get_response
model_config=model_config_for_trace(model_settings, base_url=self._client.base_url),
File "src/agents/models/_trace.py", line 34, in model_config_for_trace
config = model_settings.to_traceable_dict()
File "src/agents/model_settings.py", line 296, in to_traceable_dict
payload = self.to_json_dict()
File "src/agents/model_settings.py", line 292, in to_json_dict
return cast(dict[str, Any], TypeAdapter(ModelSettings).dump_python(self, mode="json"))
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'openai.Timeout'>
Repro steps
Self-contained, no network and no API key: the OpenAI client is given a controlled httpx transport, which is the boundary docs/testing.md recommends for provider-level behavior.
"""ModelSettings.extra_args with a provider-native value aborts the run."""
import asyncio
try: # openai>=3 ships the httpx2 package
import httpx2 as httpx
except ModuleNotFoundError: # pragma: no cover
import httpx
from openai import AsyncOpenAI
from agents import (
Agent,
ModelSettings,
OpenAIChatCompletionsModel,
RunConfig,
Runner,
set_tracing_disabled,
)
set_tracing_disabled(True)
CHAT_COMPLETION = {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 0,
"model": "gpt-4.1",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "Hello from the provider."},
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
requests_seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
requests_seen.append(str(request.url))
return httpx.Response(200, json=CHAT_COMPLETION)
def build_agent(model_settings: ModelSettings) -> Agent:
client = AsyncOpenAI(
api_key="test-key",
base_url="https://example.invalid/v1",
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
return Agent(
name="Assistant",
model=OpenAIChatCompletionsModel(model="gpt-4.1", openai_client=client),
model_settings=model_settings,
)
async def run_case(label: str, model_settings: ModelSettings) -> None:
requests_seen.clear()
try:
result = await Runner.run(
build_agent(model_settings),
"Say hello.",
run_config=RunConfig(tracing_disabled=True),
)
print(f"{label}: final_output={result.final_output!r} http_requests={len(requests_seen)}")
except Exception as exc:
print(f"{label}: {type(exc).__name__}: {exc} http_requests={len(requests_seen)}")
async def main() -> None:
await run_case("control (no extra_args) ", ModelSettings(temperature=0.2))
await run_case(
"extra_args timeout=httpx.Timeout",
ModelSettings(temperature=0.2, extra_args={"timeout": httpx.Timeout(30.0)}),
)
asyncio.run(main())
Output:
control (no extra_args) : final_output='Hello from the provider.' http_requests=1
extra_args timeout=httpx.Timeout: PydanticSerializationError: Unable to serialize unknown type: <class 'openai.Timeout'> http_requests=0
The root cause is reachable in three lines, without any model call:
>>> from openai import NOT_GIVEN
>>> from agents import ModelSettings
>>> ModelSettings(extra_args={"timeout": NOT_GIVEN}).to_traceable_dict()
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'openai.NotGiven'>
Values that fail today, all of which are ordinary extra_args contents for the Chat Completions family:
ModelSettings(...) |
to_traceable_dict() on fbf59a40 |
|---|---|
extra_args={"timeout": httpx.Timeout(30.0)} |
PydanticSerializationError: <class 'openai.Timeout'> |
extra_args={"timeout": NOT_GIVEN} |
PydanticSerializationError: <class 'openai.NotGiven'> |
extra_args={"client": AsyncOpenAI(api_key="k")} |
PydanticSerializationError: <class 'openai.AsyncOpenAI'> |
temperature=0.1 |
ok |
(extra_body values do have to be JSON serializable — they end up in the HTTP body — so that field is not part of this report.)
Expected behavior
A per-request timeout object, a NOT_GIVEN sentinel, or any other provider-native extra_args value should reach the provider call, and the traced model config should keep excluding request extras. Serializing only the fields that are actually traced is enough:
def to_traceable_dict(self) -> dict[str, Any]:
"""Serialize settings for tracing without provider-specific request extras."""
payload = cast(
dict[str, Any],
TypeAdapter(ModelSettings).dump_python(
self, mode="json", include=set(_TRACEABLE_MODEL_SETTING_FIELDS)
),
)
return {key: payload[key] for key in _TRACEABLE_MODEL_SETTING_FIELDS if key in payload}
With that change the script above prints final_output='Hello from the provider.' http_requests=1 for both cases, and the existing test_traceable_serialization_omits_request_extras still passes unchanged. to_json_dict() is public and keeps its current behavior.
I'm happy to open a PR with this fix plus regression coverage (one to_traceable_dict test with a non-serializable extra_args value, and one Chat Completions adapter test asserting the value reaches chat.completions.create).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/agents/model_settings.py with to_traceable_dict and compare its behavior with _TRACEABLE_MODEL_SETTING_FIELDS, then inspect the Chat Completions call sites in src/agents/models/openai_chatcompletions.py. Run the existing test_traceable_serialization_omits_request_extras coverage and add regression coverage for a non-serializable extra_args value. Done means tracing no longer aborts and the provider-native value reaches the Chat Completions request.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend-api-design, testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100