openai / openai/openai-agents-python

ModelSettings.extra_args with a provider-native value aborts every Chat Completions model call with PydanticSerializationError

Open
#5,002 2 comments 0 reactions 0 assignees View on GitHub

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 covers extra_args serialization.
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:

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: main at fbf59a40, and v0.22.2 (the function is byte-identical in the release tag)
  • Related library versions: openai 3.0.0, pydantic 2.12.3, httpx2 2.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_args value 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.