-
Notifications
You must be signed in to change notification settings - Fork 131
feat: add websocket_mode for responses api #990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+1,242
−92
Merged
Changes from 6 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d1cc784
feat(api): add websocket_mode for responses api
praneeth999 16a37e8
feat(config): add websocket config files
praneeth999 ca81922
add WebSocket transport for responses
praneeth999 162ce2e
exclude client-only params and handle websocket
praneeth999 c80c38f
test(websocket): add extensive websocket-mode for Response backend
praneeth999 7026172
fix(formatter): retain message identity for reasoning pairing in resp…
praneeth999 879091c
Implement WebSocket support in ResponseBackend
praneeth999 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| """ | ||
| WebSocket transport for the OpenAI Responses API. | ||
| Persistent connection for response.create events. | ||
| See https://developers.openai.com/api/docs/guides/websocket-mode/ | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| from typing import Any | ||
|
|
||
| import websockets | ||
| from websockets.protocol import State as WSState | ||
|
|
||
| from ..logger_config import logger | ||
|
|
||
| DEFAULT_WS_URL = "wss://api.openai.com/v1/responses" | ||
| MAX_RECONNECT_ATTEMPTS = 3 | ||
| RECONNECT_BASE_DELAY = 1.0 | ||
|
|
||
|
|
||
| class WebSocketConnectionError(Exception): | ||
| """Raised when a WebSocket connection cannot be established.""" | ||
|
|
||
|
|
||
| def _extract_error_details(event: dict[str, Any]) -> tuple[str, str]: | ||
| """Extract message and code from websocket error events.""" | ||
| nested_error = event.get("error") | ||
| if isinstance(nested_error, dict): | ||
| return ( | ||
| nested_error.get("message", "Unknown error"), | ||
| nested_error.get("code", ""), | ||
| ) | ||
| return (event.get("message", "Unknown error"), event.get("code", "")) | ||
|
|
||
|
|
||
| class WebSocketResponseTransport: | ||
| """Persistent WebSocket transport for the OpenAI Responses API.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| api_key: str, | ||
| url: str = DEFAULT_WS_URL, | ||
| organization: str | None = None, | ||
| ): | ||
| self.api_key = api_key | ||
| self.url = url | ||
| self.organization = organization | ||
| self._ws = None | ||
|
|
||
| def _build_headers(self) -> dict[str, str]: | ||
| headers = { | ||
| "Authorization": f"Bearer {self.api_key}", | ||
| } | ||
| if self.organization: | ||
| headers["OpenAI-Organization"] = self.organization | ||
| return headers | ||
|
|
||
| def _build_response_create_event(self, payload: dict[str, Any]) -> str: | ||
| """Wrap an API params dict as a response.create WebSocket event.""" | ||
| event = {"type": "response.create", **payload} | ||
| return json.dumps(event) | ||
|
|
||
| async def connect(self) -> None: | ||
| """Establish the WebSocket connection with retry logic.""" | ||
| headers = self._build_headers() | ||
| last_error: Exception | None = None | ||
|
|
||
| for attempt in range(MAX_RECONNECT_ATTEMPTS): | ||
| try: | ||
| self._ws = await websockets.connect( | ||
| self.url, | ||
| additional_headers=headers, | ||
| max_size=None, | ||
| ping_interval=30, | ||
| ping_timeout=10, | ||
| ) | ||
| logger.info( | ||
| f"[WebSocket] Connected to {self.url} (attempt {attempt + 1})", | ||
| ) | ||
| return | ||
| except Exception as e: | ||
| last_error = e | ||
| if attempt < MAX_RECONNECT_ATTEMPTS - 1: | ||
| delay = RECONNECT_BASE_DELAY * (2**attempt) | ||
| logger.warning( | ||
| f"[WebSocket] Connection attempt {attempt + 1} failed: {e}. Retrying in {delay}s...", | ||
| ) | ||
| await asyncio.sleep(delay) | ||
|
|
||
| raise WebSocketConnectionError( | ||
| f"Failed to connect to {self.url} after {MAX_RECONNECT_ATTEMPTS} attempts: {last_error}", | ||
| ) | ||
|
|
||
| async def send_and_receive( | ||
| self, | ||
| api_params: dict[str, Any], | ||
| ): | ||
| """Send a response.create event and yield parsed response events. | ||
|
|
||
| Args: | ||
| api_params: The API params dict (same as HTTP body, minus stream/background). | ||
|
|
||
| Yields: | ||
| Parsed event dicts with a "type" field matching the HTTP SSE event types | ||
| (e.g. "response.output_text.delta", "response.completed"). | ||
| """ | ||
| if self._ws is None: | ||
| raise WebSocketConnectionError("Not connected. Call connect() first.") | ||
|
|
||
| message = self._build_response_create_event(api_params) | ||
| await self._ws.send(message) | ||
| logger.debug("[WebSocket] Sent response.create event") | ||
|
|
||
| async for raw_message in self._ws: | ||
| event = json.loads(raw_message) | ||
| event_type = event.get("type", "") | ||
| logger.debug(f"[WebSocket] Received event: {event_type}") | ||
|
|
||
| if event_type == "error": | ||
| error_msg, error_code = _extract_error_details(event) | ||
| logger.error( | ||
| f"[WebSocket] Server error: {error_msg} (code={error_code})", | ||
| ) | ||
| raise WebSocketConnectionError( | ||
| f"WebSocket response.create failed: {error_msg}" + (f" (code={error_code})" if error_code else ""), | ||
| ) | ||
|
|
||
| yield event | ||
|
|
||
| if event_type in ( | ||
| "response.completed", | ||
| "response.incomplete", | ||
| "response.failed", | ||
| ): | ||
| break | ||
|
|
||
| async def close(self) -> None: | ||
| """Close the WebSocket connection.""" | ||
| if self._ws is not None: | ||
| try: | ||
| await self._ws.close() | ||
| logger.info("[WebSocket] Connection closed") | ||
| except Exception as e: | ||
| logger.warning(f"[WebSocket] Error closing connection: {e}") | ||
| finally: | ||
| self._ws = None | ||
|
|
||
| @property | ||
| def is_connected(self) -> bool: | ||
| return self._ws is not None and self._ws.state == WSState.OPEN |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| # GPT-5.2 with WebSocket Mode | ||
| # Single agent using the latest GPT-5.2 model over persistent WebSocket. | ||
| agents: | ||
| - id: "gpt-5-2-ws" | ||
| backend: | ||
| type: "openai" | ||
| model: "gpt-5.2" | ||
| websocket_mode: true | ||
| enable_web_search: true | ||
| enable_code_interpreter: true | ||
| ui: | ||
| display_type: "textual_terminal" | ||
| logging_enabled: true |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # GPT-5-nano with WebSocket Mode | ||
| # Single agent with persistent WebSocket connection. | ||
| agents: | ||
| - id: "gpt-5-nano-ws" | ||
| backend: | ||
| type: "openai" | ||
| model: "gpt-5-nano" | ||
| websocket_mode: true | ||
| enable_code_interpreter: true | ||
| ui: | ||
| display_type: "textual_terminal" | ||
| logging_enabled: true |
18 changes: 18 additions & 0 deletions
18
massgen/configs/providers/openai/multi_model_websocket.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # Multi-Model WebSocket Mode | ||
| # Two agents with different models, both using WebSocket transport. | ||
| agents: | ||
| - id: "gpt-5-2" | ||
| backend: | ||
| type: "openai" | ||
| model: "gpt-5.2" | ||
| websocket_mode: true | ||
| enable_code_interpreter: true | ||
| - id: "gpt-5-nano" | ||
| backend: | ||
| type: "openai" | ||
| model: "gpt-5-nano" | ||
| websocket_mode: true | ||
| enable_code_interpreter: true | ||
| ui: | ||
| display_type: "textual_terminal" | ||
| logging_enabled: true |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mutating
all_paramsmay cause unintended side effects.The
all_params.pop("background", None)call modifies the input dictionary in place. If the caller reusesall_paramsafter this call, thebackgroundkey will be unexpectedly missing. Consider operating on a copy or documenting this mutation.🛡️ Suggested fix to avoid mutation
websocket_mode = all_params.get("websocket_mode", False) # In WebSocket mode, stream/background are not used (transport handles streaming) api_params = {"input": converted_messages} if not websocket_mode: api_params["stream"] = True else: - all_params.pop("background", None) + # Don't include 'background' in api_params for WebSocket mode + # (handled below via excluded params or explicit skip)Alternatively, if
backgroundmust be removed to prevent it from being added later in the loop, ensure this mutation is documented in the docstring.🤖 Prompt for AI Agents