# 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).*