Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "3.19.2"
".": "3.20.0"
}
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## [3.20.0](https://github.com/openai/openai-python/compare/v3.19.2...v3.20.0) (2026-09-28)


### Features

* **api:** add Agents credential and session options ([#3967](https://github.com/openai/openai-python/issues/3967)) ([bb68198](https://github.com/openai/openai-python/commit/bb68198a4bb4a161cbb32a28c3888a51540eaf0f))
* **api:** add Cyber access programs to Responses ([#3956](https://github.com/openai/openai-python/issues/3956)) ([09c5b6f](https://github.com/openai/openai-python/commit/09c5b6f13f716ad4e417fd7ba9209a8a057e0383))
* **responses:** opt in to incremental WebSocket text and tool snapshots ([#3973](https://github.com/openai/openai-python/issues/3973)) ([d0207b4](https://github.com/openai/openai-python/commit/d0207b48c043741ff483d7a8c945f8003d1ee714))
* **responses:** preserve detailed WebSocket accumulator snapshots ([#3981](https://github.com/openai/openai-python/issues/3981)) ([a380cf2](https://github.com/openai/openai-python/commit/a380cf256abc2aa51cbf5d49437ae039b9aa9b12))


### Bug Fixes

* **client:** retry unmapped TLS transport errors ([#3982](https://github.com/openai/openai-python/issues/3982)) ([0d35a26](https://github.com/openai/openai-python/commit/0d35a2640458b64ed2ff4a7c9b1f1a1e5705336e))
* **live:** avoid hangs at fractional transcript grouping deadlines ([#3970](https://github.com/openai/openai-python/issues/3970)) ([4ef4129](https://github.com/openai/openai-python/commit/4ef4129e85e81a285905755e57b392aa9e77f9bd))
* **live:** keep query parameters out of WebSocket endpoint paths ([#3972](https://github.com/openai/openai-python/issues/3972)) ([f9c458b](https://github.com/openai/openai-python/commit/f9c458b759e3756979fceebf50bffa78d9cf0b97))
* **live:** preserve caller queues and prevent uncertain WebSocket replay ([#3980](https://github.com/openai/openai-python/issues/3980)) ([80e9686](https://github.com/openai/openai-python/commit/80e96860b5ccbfcf4a1c9dd9947fc31cd021037a))
* **realtime:** preserve base URL queries in WebSocket upgrades ([#3971](https://github.com/openai/openai-python/issues/3971)) ([f7bd4a7](https://github.com/openai/openai-python/commit/f7bd4a703cad904e4f5d91ec9d7abac70d8c0bd6))
* **realtime:** retain configured queues without replaying attempted sends ([#3978](https://github.com/openai/openai-python/issues/3978)) ([a52805c](https://github.com/openai/openai-python/commit/a52805c2537422ad602eba2bf66072880ea3fd22))


### Chores

* **api:** clarify documented API error responses ([#3965](https://github.com/openai/openai-python/issues/3965)) ([384fee3](https://github.com/openai/openai-python/commit/384fee3252e2336a85450ff38f9bae41f9199616))
* **api:** document batch error responses ([#3961](https://github.com/openai/openai-python/issues/3961)) ([6e4a79c](https://github.com/openai/openai-python/commit/6e4a79cc8c7e640e7ac4be710db32fe20b1020f2))
* **api:** document files and uploads error responses ([#3960](https://github.com/openai/openai-python/issues/3960)) ([a9d727f](https://github.com/openai/openai-python/commit/a9d727ff9c4a38fc5dca8d31bd3dd76f473cfc27))
* **api:** document fine-tuning and model errors ([#3964](https://github.com/openai/openai-python/issues/3964)) ([5d4003c](https://github.com/openai/openai-python/commit/5d4003c12d5df5a5faed35971cac3bcb711fdf7d))
* **api:** document Responses not-found errors ([#3959](https://github.com/openai/openai-python/issues/3959)) ([63099e7](https://github.com/openai/openai-python/commit/63099e739d25cb18f90cae93648471bf037bc46f))
* **api:** document stored chat completion errors ([#3963](https://github.com/openai/openai-python/issues/3963)) ([a73fe0c](https://github.com/openai/openai-python/commit/a73fe0c3d404335a342d1251bd32709b8e3d76f2))

## [3.19.2](https://github.com/openai/openai-python/compare/v3.19.1...v3.19.2) (2026-09-23)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "openai"
version = "3.19.2"
version = "3.20.0"
description = "The official Python library for the openai API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down
8 changes: 6 additions & 2 deletions src/openai/_httpx2.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import ssl
import sys
from typing import Any, Protocol, cast

import anyio
import httpx2

from ._constants import DEFAULT_TIMEOUT, DEFAULT_CONNECTION_LIMITS
Expand Down Expand Up @@ -100,9 +102,11 @@ def timeout_exceptions() -> tuple[type[httpx2.TimeoutException], ...]:
return (httpx2.TimeoutException,) if module is None else (httpx2.TimeoutException, module.TimeoutException)


def request_exceptions() -> tuple[type[httpx2.RequestError], ...]:
def request_exceptions() -> tuple[type[Exception], ...]:
module = _loaded_legacy_httpx()
return (httpx2.RequestError,) if module is None else (httpx2.RequestError, module.RequestError)
# Shim unmapped AnyIO TLS failures pending https://github.com/pydantic/httpx2/issues/854.
errors = (httpx2.RequestError, ssl.SSLError, anyio.EndOfStream)
return errors if module is None else (*errors, module.RequestError)


def status_exceptions() -> tuple[type[httpx2.HTTPStatusError], ...]:
Expand Down
35 changes: 24 additions & 11 deletions src/openai/_send_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,26 +37,34 @@ def enqueue(self, data: str) -> None:
self._queue.append((data, byte_length))
self._bytes += byte_length

def flush_sync(self, send: typing.Callable[[str], object]) -> None:
def flush_sync(self, send: typing.Callable[[str], object], *, requeue_failed: bool = True) -> None:
"""Send every queued message via *send*.

If *send* raises, the failing message and all subsequent messages
are re-queued and the error is re-raised.
are re-queued and the error is re-raised. When `requeue_failed` is
false, release the attempted message even on failure or interruption.
"""
while isinstance(pending := self._begin_flush(), threading.Event):
pending.wait()

try:
while pending:
data, byte_length = pending[0]
send(data)
with self._lock:
pending.popleft()
self._bytes -= byte_length
sent = False
try:
send(data)
sent = True
finally:
if sent or not requeue_failed:
with self._lock:
pending.popleft()
self._bytes -= byte_length
finally:
self._end_flush(pending)

async def flush_async(self, send: typing.Callable[[str], typing.Awaitable[object]]) -> None:
async def flush_async(
self, send: typing.Callable[[str], typing.Awaitable[object]], *, requeue_failed: bool = True
) -> None:
"""Async variant of :meth:`flush_sync`."""
while isinstance(pending := self._begin_flush(), threading.Event):
# Waiting in a worker keeps the event loop responsive. Cancellation
Expand All @@ -66,10 +74,15 @@ async def flush_async(self, send: typing.Callable[[str], typing.Awaitable[object
try:
while pending:
data, byte_length = pending[0]
await send(data)
with self._lock:
pending.popleft()
self._bytes -= byte_length
sent = False
try:
await send(data)
sent = True
finally:
if sent or not requeue_failed:
with self._lock:
pending.popleft()
self._bytes -= byte_length
finally:
self._end_flush(pending)

Expand Down
2 changes: 1 addition & 1 deletion src/openai/_version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__title__ = "openai"
__version__ = "3.19.2" # x-release-please-version
__version__ = "3.20.0" # x-release-please-version
33 changes: 33 additions & 0 deletions src/openai/lib/live/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Live WebSocket lifecycle

`client.live.connect()` and `client.live.forks.connect(session_id=...)` leave
startup to the caller: send `connection.session.start(...)` and wait for
`session.started`. A sideband connection created by
`client.live.sideband.connect(session_id=...)` attaches to an existing session.
It does not send a start or require a new `session.started`; an attachment's
short replay is not a complete session snapshot.

Direct `recv()` and `recv_bytes()` calls report transport errors to their
caller. Iterator reconnection is available only when an `on_reconnecting`
callback was explicitly supplied. The callback controls retry and can update
credentials or query parameters. A new socket is not proof that the previous
Live session, recording, or application state was restored. Applications own
their recovery decision and any necessary startup or state reconstruction.
Don't use multiple physical readers: a dispatcher owns the read loop while it
runs. Detaching or closing one transcript grouper doesn't cancel another
observer or the connection.

All modes preserve the manager's caller-configured `max_queue_size`, including
if the queue was empty at connection time. Only messages that haven't been
attempted on a socket remain eligible for flushing after a retry. A direct send
exception is raised to its caller and cannot prove the server didn't receive
that message. A failure while flushing an already queued message still logs a
warning, as before; it cannot be raised at the original queueing call. Neither
failed attempt is automatically retried. Unattempted pre-open messages and
messages explicitly queued during recovery remain in order within their
existing budget. No automatic session reopening or restoration is implied.

Treat each wire error as an event to handle, not as a successful session result.
In particular, preserve `session_storage_failed` if `session.closed` follows;
the close doesn't mean that recording storage succeeded. Unknown events and
fields are preserved for callers that need them.
41 changes: 40 additions & 1 deletion src/openai/lib/responses_websocket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,47 @@ a changed nonempty item ID starts fresh at its index. A supplied response
output list overrides the projected items, including an explicit empty list;
omitted or null output retains only the helper's earlier projections.

`detailed_snapshot()` returns a separate mutable view when you need the fields
beyond selected text and tool inputs. It contains `stream_id`, `response_id`,
`terminal_type`, `response` and `output`. `response` is the last observed
lifecycle response metadata (excluding `output`), or `None` if none arrived.
`output` is a list of `{"output_index": index, "item": metadata, "content": rows}`.
Each content row is `{"content_index": index, "part": observed_fields}`. The
part's `annotations`, when present as a list, use
`{"annotation_index": index, "annotation": observed_fields}` rows. Indices may
be sparse; list position is not the API index.
For a message that has no projected content, the row's `content` is omitted,
null or empty according to what was actually received.

For example, after collecting an item or terminal as above:

```python
details = accumulator.detailed_snapshot()
for output in details["output"]:
for content in output.get("content") or []:
part = content["part"]
# Fields exist only if received: a WS delta may have no part/item type.
text = part.get("text")
citations = part.get("annotations")
token_scores = part.get("logprobs")
```

Logprobs accumulate with text deltas and a supplied `output_text.done.logprobs`
replaces them, including empty or null values. Content/item/lifecycle replacements
also replace their corresponding annotations and other metadata; text-only done
events do not erase citations. Refusal, tool/MCP and unknown item/part fields are
retained as observed, with unset and null distinct. Unknown standalone events still
pass through unchanged and are not accumulated. Annotation events enrich matching
known items on the accumulator's lane; annotations received before any matching
item/text remain available in the original event and do not start or replace a turn.
A partial field or unknown type
is provisional, not a fabricated validated response or a successful tool result.
You may mutate this returned view without changing the accumulator, events, or
earlier snapshots. The original `snapshot()` remains immutable and hashable.

`snapshot()` materializes the entire current projection and joins retained
fragments. It is proportional to the accumulated output, so requesting it after
fragments. `detailed_snapshot()` also materializes and copies its whole projection.
Both are proportional to the accumulated output, so requesting either after
every delta or completed item repeatedly rebuilds growing prefixes. Use the
original event for progress, including the final item itself on
`response.output_item.done`. Read the full snapshot at a terminal or on explicit
Expand Down
Loading
Loading