__discriminator__ AttributeError in Python 3.14 and SDK 2.6.1

# Python 3.14 Breaks `openai` SDK 2.6.1 Responses API — `typing.Union._discriminator_` AttributeError

**Environment:** Python 3.14, `openai==2.6.1`, any call to `client.responses.create()` that returns a web search response.

## The Error

Every call to `client.responses.create()` with `tools=[{“type”: “web_search”}]` raises:

```

AttributeError: ‘typing.Union’ object has no attribute ‘_discriminator_’

and no _dict_ for setting new attributes

```

Full traceback bottom:

```

File …/openai/_models.py:697, in _build_discriminated_union_meta(…)

697     cast(CachedDiscriminatorType, union).\__discriminator_\_ = details

AttributeError: ‘typing.Union’ object has no attribute ‘_discriminator_’

and no _dict_ for setting new attributes

```

## Root Cause

The OpenAI SDK uses a custom Pydantic deserialization path for response objects

(`openai/_models.py`, `construct_type()`). When deserializing the `output` field

of a Responses API response — which is a `List[ResponseOutputItem]`, where

`ResponseOutputItem` is a discriminated union — the SDK calls

`_build_discriminated_union_meta()` to build and **cache** discriminator metadata.

The caching line is:

```python

# openai/_models.py:697

cast(CachedDiscriminatorType, union)._discriminator_ = details

```

This tries to set a custom attribute (`_discriminator_`) directly on a

`typing.Union` object so the discriminator details don’t have to be recomputed

on subsequent calls.

**This worked in Python ≤ 3.13** because `typing.Union` objects had a `_dict_`

and allowed arbitrary attribute assignment.

**Python 3.14 removed this.** `typing.Union` objects are now immutable — no

`_dict_`, no arbitrary attribute assignment — and the line raises `AttributeError`.

The error fires inside `construct_type()`, which is called during

`api_response.parse()`, which is called inside `client.responses.create()` before

the call returns. You never get a response object back. There is no way to

catch this with a try/except around `response.output_text` or similar — the

exception surfaces from inside the SDK’s response deserialization.

## Why It’s Intermittent

On a fresh Jupyter kernel (or fresh Python process), the first Responses API call

that successfully reaches the `_build_discriminated_union_meta()` caching line

fails. However: if any prior call in the same process happened to succeed (e.g.,

a call whose response had no output items, or a non-Responses API call that cached

the same union type through a different code path), subsequent calls may skip the

caching step and succeed. This produces the appearance of random, call-dependent

failures. It is not random — it is a race between which code path populates the

union’s discriminator cache first. After a kernel restart, the cache is cold and

the error is consistent.

## Affected Versions

Confirmed broken: `openai==2.6.1` on Python 3.14.

The caching pattern in `_build_discriminated_union_meta` has been present since

at least `openai==1.x`. Any SDK version that uses this caching approach is broken

on Python 3.14. This includes all versions of the SDK that support the Responses

API (`openai>=1.66.0` approximately).

## Workaround: Bypass the SDK, Use `httpx` Directly

The Responses API is a standard HTTP endpoint. Skip the SDK’s Pydantic machinery

entirely and call it with `httpx` (already a transitive dependency of the `openai`

package):

```python

import httpx

def call_responses_api(

prompt: str,

api_key: str,

model: str = “gpt-5-nano”,

timeout: float = 120.0,

) → dict:

“”"Call OpenAI Responses API directly via httpx, bypassing SDK Pydantic parsing.

Workaround for Python 3.14 incompatibility in openai SDK:

typing.Union objects are immutable in 3.14; the SDK's discriminated-union

caching code sets \__discriminator_\_ on them and raises AttributeError.

"""

with httpx.Client(timeout=timeout) as http:

    resp = http.post(

“h..ps://api.openai.com/v1/responses”, # had to break the link to post

headers={

“Authorization”: f"Bearer {api_key}",

“Content-Type”: “application/json”,

        },

json={

“model”: model,

“tools”: [{“type”: “web_search”}],

“input”: prompt,

        },

    )

    resp.raise_for_status()

return resp.json() # plain dict — no Pydantic, no discriminator issue

def extract_text(response_dict: dict) → str:

“”“Extract the model’s text output from a raw Responses API dict.”“”

output = response_dict.get("output", \[\])

if output:

    content = output\[-1\].get("content", \[\])

if content:

return content[-1].get(“text”, “”)

return “”

```

This produces identical results to the SDK path — same model, same tools, same

response content — with no Pydantic layer and no Python version constraints.

## The Fix

The SDK’s `_build_discriminated_union_meta` function should store the cached

discriminator details somewhere other than as an attribute on the `typing.Union`

object itself — for example, in a module-level `WeakValueDictionary` keyed by

the union type’s identity. This would be compatible with Python 3.14’s immutable

`typing.Union` objects.

```python

# Suggested fix sketch (openai/_models.py)

import weakref

_DISCRIMINATOR_CACHE: dict[int, DiscriminatorDetails] = {}

def _build_discriminated_union_meta(union, meta_annotations):

# … existing logic to build `details` …

union_id = id(union)

if union_id not in _DISCRIMINATOR_CACHE:

_DISCRIMINATOR_CACHE[union_id] = details

return _DISCRIMINATOR_CACHE.get(union_id)

```

(A `WeakValueDictionary` or `WeakKeyDictionary` would be preferable to avoid

holding strong references to type objects, but the core fix is moving the cache

off the type object itself.)

*Discovered while building a legislation analysis pipeline on Python 3.14 / macOS M4.*

*Analysis by Matt Baldwin (Triad Executive Advisors) with the help of Claude (Anthropic).*

4 Likes