Skip to content
Closed
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
22 changes: 22 additions & 0 deletions src/openai/lib/responses_websocket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@ async def run():
return response
```

The same helpers accept native `client.beta.responses.connect()` connections.
The constructor infers the connection family: Beta lanes accept Beta requests,
return Beta events, and collect a native `BetaResponse`. Stable connections and
bare session/lane annotations retain their existing stable types and defaults.
Use the native connection class as the type argument when explicitly annotating
a Beta session or lane. The async helpers follow the same rule.

For caller-fed Beta events, select their response model when constructing the
accumulator:

```python
from openai.types.beta.beta_response import BetaResponse
from openai.lib.responses_websocket import ResponsesWebSocketAccumulator

beta_accumulator = ResponsesWebSocketAccumulator(response_type=BetaResponse)
# Feed events from one Beta connection or managed lane, as in the example below.
```

The default `ResponsesWebSocketAccumulator()` keeps stable response types.
`reset()` retains the selected family. Beta finals preserve their native fields,
including opaque and encrypted data; the helper does not add parsed Beta models.

The session owns the connection's read side and lifetime. Its one reader uses the
connection's raw receive, event parsing, and recovery methods. While the session
is active, receive through its lanes instead of calling the connection's `recv`,
Expand Down
40 changes: 30 additions & 10 deletions src/openai/lib/responses_websocket/_accumulator.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from __future__ import annotations

from copy import deepcopy
from typing import Any, Literal, cast
from typing import Any, Generic, Literal, cast
from dataclasses import field, dataclass
from typing_extensions import TypeVar, overload

from ._session import ResponsesWebSocketError, _field
from ._session import ResponsesWebSocketError, _field, _ServerEvent
from ..._compat import PYDANTIC_V1, model_copy
from ..._models import BaseModel
from ...types.responses import Response
from ...types.beta.beta_response import BetaResponse
from ...types.beta.beta_responses_server_event import BetaResponsesServerEvent
from ...types.responses.responses_server_event import ResponsesServerEvent

_RichField = Literal["summary", "reasoning", "code", "command", "stdout", "stderr"]
Expand Down Expand Up @@ -42,6 +45,8 @@
"response.shell_call_output_content.done": "shell_call_output",
}

_ResponseT = TypeVar("_ResponseT", Response, BetaResponse, default=Response)


@dataclass(frozen=True)
class ResponsesWebSocketOutput:
Expand Down Expand Up @@ -89,7 +94,7 @@ class _Output:
shell_output: dict[str, dict[str, object]] = field(default_factory=dict[str, dict[str, object]])


class ResponsesWebSocketAccumulator:
class ResponsesWebSocketAccumulator(Generic[_ResponseT]):
"""Caller-fed text/tool inputs and detailed reasoning, code and shell progress.

Feed typed events from a connection or lane's recv(). Use one accumulator per
Expand All @@ -99,7 +104,16 @@ class ResponsesWebSocketAccumulator:
server response, including failed/incomplete and missing/null/empty output.
"""

def __init__(self) -> None:
@overload
def __init__(
self: ResponsesWebSocketAccumulator[Response], *, response_type: type[Response] = Response
) -> None: ...
Comment thread
markstuart-oai marked this conversation as resolved.
Resolved

@overload
def __init__(self: ResponsesWebSocketAccumulator[BetaResponse], *, response_type: type[BetaResponse]) -> None: ...
Comment thread
markstuart-oai marked this conversation as resolved.
Resolved

def __init__(self, *, response_type: type[Response] | type[BetaResponse] = Response) -> None:
self._response_type = response_type
self._stream_id: str | None = None
self._response_id: str | None = None
self._terminal_type: str | None = None
Expand All @@ -108,7 +122,7 @@ def __init__(self) -> None:
# non-negative indices without decimal string conversion limits.
self._output: dict[str, _Output] = {}
self._response: dict[str, object] | None = None
self._final: Response | None = None
self._final: _ResponseT | None = None
self._error: Exception | None = None
self._shell_command_counts: dict[str, int] = {}

Expand Down Expand Up @@ -231,7 +245,7 @@ def detailed_snapshot(self) -> dict[str, Any]:
"output": output,
}

def get_final_response(self) -> Response:
def get_final_response(self) -> _ResponseT:
"""Return an independent copy of the received terminal response, never a partial success."""
error = self._error
if error is not None:
Expand All @@ -240,7 +254,13 @@ def get_final_response(self) -> Response:
raise RuntimeError("No terminal response has been received")
return model_copy(self._final, deep=True)

def add_event(self, event: ResponsesServerEvent) -> None:
@overload
def add_event(self: ResponsesWebSocketAccumulator[Response], event: ResponsesServerEvent) -> None: ...
Comment thread
markstuart-oai marked this conversation as resolved.
Resolved

@overload
def add_event(self: ResponsesWebSocketAccumulator[BetaResponse], event: BetaResponsesServerEvent) -> None: ...
Comment thread
markstuart-oai marked this conversation as resolved.
Resolved

def add_event(self, event: _ServerEvent) -> None:
"""Observe an event without consuming or changing it; unknown events are ignored.

Final fields replace deltas. A supplied output list, even [], replaces
Expand Down Expand Up @@ -315,7 +335,7 @@ def add_event(self, event: ResponsesServerEvent) -> None:
terminal = kind in {"response.completed", "response.failed", "response.incomplete"}
if kind in {"response.created", "response.queued", "response.in_progress"} or terminal:
response = _field(event, "response")
if not isinstance(response, Response):
if not isinstance(response, self._response_type):
error = ValueError("WebSocket event is missing a valid response")
if terminal:
self._error = error
Expand Down Expand Up @@ -351,7 +371,7 @@ def add_event(self, event: ResponsesServerEvent) -> None:
self._response_id = response_id or self._response_id
self._bound, self._stream_id = True, stream_id
if terminal:
self._final = model_copy(response, deep=True)
self._final = cast(_ResponseT, model_copy(response, deep=True))
self._terminal_type = kind
return
if kind in _RICH_TEXT_EVENTS or kind in _RICH_STATUS_EVENTS or kind in _RICH_PART_EVENTS:
Expand Down Expand Up @@ -487,7 +507,7 @@ def add_event(self, event: ResponsesServerEvent) -> None:
elif kind == "response.custom_tool_call_input.done":
item.input = [value]

def _add_rich_event(self, event: ResponsesServerEvent, kind: str) -> None:
def _add_rich_event(self, event: _ServerEvent, kind: str) -> None:
index = _index_field(event, "output_index")
# Shell-command events identify their call by output_index only. An
# unmodeled item_id extra must stay raw, not become a routing guard.
Expand Down
Loading
Loading