|
6 | 6 |
|
7 | 7 | import httpx2 |
8 | 8 | import pytest |
| 9 | +from websockets.sync.client import ClientConnection |
9 | 10 | from websockets.sync.server import ServerConnection |
| 11 | +from websockets.asyncio.client import ClientConnection as AsyncClientConnection |
10 | 12 |
|
11 | 13 | from openai import OpenAI, AsyncOpenAI |
| 14 | +from openai._exceptions import WebSocketQueueFullError |
| 15 | +from openai.types.websocket_reconnection import ReconnectingEvent |
12 | 16 | from openai.types.realtime.realtime_error_event import RealtimeErrorEvent |
13 | 17 | from openai.types.realtime.input_audio_buffer_cleared_event import InputAudioBufferClearedEvent |
14 | 18 |
|
@@ -133,3 +137,136 @@ def script(socket: ServerConnection) -> None: |
133 | 137 | "event_id": "future-1", |
134 | 138 | "text": "東京🙂", |
135 | 139 | } |
| 140 | + |
| 141 | + |
| 142 | +@pytest.mark.parametrize("mode", ["sync", "async"]) |
| 143 | +async def test_realtime_empty_manager_queue_keeps_budget_after_open(mode: str) -> None: |
| 144 | + opened = 0 |
| 145 | + |
| 146 | + def script(socket: ServerConnection) -> None: |
| 147 | + nonlocal opened |
| 148 | + opened += 1 |
| 149 | + if opened == 1: |
| 150 | + socket.close(code=1011, reason="synthetic restart") |
| 151 | + return |
| 152 | + # Let the iterator resume even if its connection lost the manager's |
| 153 | + # queue. Server-side inspection still proves exactly what was sent. |
| 154 | + socket.send('{"type": "input_audio_buffer.cleared", "event_id": "ready"}') |
| 155 | + assert json.loads(socket.recv(timeout=5)) == {"type": "input_audio_buffer.clear", "event_id": "queued"} |
| 156 | + |
| 157 | + with script_server(script, expected_connections=2) as url: |
| 158 | + if mode == "sync": |
| 159 | + with OpenAI( |
| 160 | + api_key="fake-realtime-key", base_url=url, http_client=httpx2.Client(trust_env=False) |
| 161 | + ) as client: |
| 162 | + |
| 163 | + def on_retry(_event: ReconnectingEvent) -> None: |
| 164 | + manager.send({"type": "input_audio_buffer.clear", "event_id": "queued"}) |
| 165 | + with pytest.raises(WebSocketQueueFullError): |
| 166 | + manager.send({"type": "input_audio_buffer.append", "audio": "AAAA" * 100}) |
| 167 | + |
| 168 | + manager = client.realtime.connect( |
| 169 | + model="gpt-realtime", max_queue_size=96, on_reconnecting=on_retry, initial_delay=0 |
| 170 | + ) |
| 171 | + with manager as connection: |
| 172 | + event = next(iter(connection)) |
| 173 | + assert event.type == "input_audio_buffer.cleared" |
| 174 | + else: |
| 175 | + async with AsyncOpenAI( |
| 176 | + api_key="fake-realtime-key", base_url=url, http_client=httpx2.AsyncClient(trust_env=False) |
| 177 | + ) as client: |
| 178 | + |
| 179 | + def on_async_retry(_event: ReconnectingEvent) -> None: |
| 180 | + async_manager.send({"type": "input_audio_buffer.clear", "event_id": "queued"}) |
| 181 | + with pytest.raises(WebSocketQueueFullError): |
| 182 | + async_manager.send({"type": "input_audio_buffer.append", "audio": "AAAA" * 100}) |
| 183 | + |
| 184 | + async_manager = client.realtime.connect( |
| 185 | + model="gpt-realtime", max_queue_size=96, on_reconnecting=on_async_retry, initial_delay=0 |
| 186 | + ) |
| 187 | + async with async_manager as connection: |
| 188 | + event = await asyncio.wait_for(anext(aiter(connection)), 5) |
| 189 | + assert event.type == "input_audio_buffer.cleared" |
| 190 | + |
| 191 | + |
| 192 | +@pytest.mark.parametrize("mode", ["sync", "async"]) |
| 193 | +@pytest.mark.parametrize("preopen", [True, False], ids=["preopen-flush", "direct"]) |
| 194 | +async def test_realtime_recovery_never_replays_an_attempted_command( |
| 195 | + mode: str, preopen: bool, monkeypatch: pytest.MonkeyPatch |
| 196 | +) -> None: |
| 197 | + opened = 0 |
| 198 | + recorded: list[tuple[int, str]] = [] |
| 199 | + |
| 200 | + def script(socket: ServerConnection) -> None: |
| 201 | + nonlocal opened |
| 202 | + opened += 1 |
| 203 | + current = opened |
| 204 | + if current == 1: |
| 205 | + for _ in range(2 if preopen else 1): |
| 206 | + recorded.append((current, json.loads(socket.recv(timeout=5))["event_id"])) |
| 207 | + socket.close(code=1011, reason="synthetic restart") |
| 208 | + return |
| 209 | + socket.send('{"type": "input_audio_buffer.cleared", "event_id": "ready"}') |
| 210 | + for _ in range(2 if preopen else 1): |
| 211 | + recorded.append((current, json.loads(socket.recv(timeout=5))["event_id"])) |
| 212 | + |
| 213 | + # Fail after the real wire send: an exception cannot establish non-delivery. |
| 214 | + original = ClientConnection.send |
| 215 | + original_async = AsyncClientConnection.send |
| 216 | + |
| 217 | + def send_then_interrupt(self: ClientConnection, message: object, *args: object, **kwargs: object) -> None: |
| 218 | + original(self, message, *args, **kwargs) # type: ignore[arg-type] |
| 219 | + if json.loads(message)["event_id"] == "attempted": # type: ignore[arg-type] |
| 220 | + raise OSError("synthetic interruption after send") |
| 221 | + |
| 222 | + async def async_send_then_interrupt( |
| 223 | + self: AsyncClientConnection, message: object, *args: object, **kwargs: object |
| 224 | + ) -> None: |
| 225 | + await original_async(self, message, *args, **kwargs) # type: ignore[arg-type] |
| 226 | + if json.loads(message)["event_id"] == "attempted": # type: ignore[arg-type] |
| 227 | + raise OSError("synthetic interruption after send") |
| 228 | + |
| 229 | + if mode == "sync": |
| 230 | + monkeypatch.setattr(ClientConnection, "send", send_then_interrupt) |
| 231 | + else: |
| 232 | + monkeypatch.setattr(AsyncClientConnection, "send", async_send_then_interrupt) |
| 233 | + with script_server(script, expected_connections=2) as url: |
| 234 | + if mode == "sync": |
| 235 | + with OpenAI( |
| 236 | + api_key="fake-realtime-key", base_url=url, http_client=httpx2.Client(trust_env=False) |
| 237 | + ) as client: |
| 238 | + |
| 239 | + def on_retry(_event: ReconnectingEvent) -> None: |
| 240 | + manager.send({"type": "input_audio_buffer.clear", "event_id": "during-recovery"}) |
| 241 | + |
| 242 | + manager = client.realtime.connect(model="gpt-realtime", on_reconnecting=on_retry, initial_delay=0) |
| 243 | + if preopen: |
| 244 | + for identity in ("first", "attempted", "unattempted"): |
| 245 | + manager.send({"type": "input_audio_buffer.clear", "event_id": identity}) |
| 246 | + with manager as connection: |
| 247 | + if not preopen: |
| 248 | + with pytest.raises(OSError, match="synthetic interruption after send"): |
| 249 | + connection.send({"type": "input_audio_buffer.clear", "event_id": "attempted"}) |
| 250 | + assert next(iter(connection)).type == "input_audio_buffer.cleared" |
| 251 | + else: |
| 252 | + async with AsyncOpenAI( |
| 253 | + api_key="fake-realtime-key", base_url=url, http_client=httpx2.AsyncClient(trust_env=False) |
| 254 | + ) as client: |
| 255 | + |
| 256 | + def on_async_retry(_event: ReconnectingEvent) -> None: |
| 257 | + async_manager.send({"type": "input_audio_buffer.clear", "event_id": "during-recovery"}) |
| 258 | + |
| 259 | + async_manager = client.realtime.connect( |
| 260 | + model="gpt-realtime", on_reconnecting=on_async_retry, initial_delay=0 |
| 261 | + ) |
| 262 | + if preopen: |
| 263 | + for identity in ("first", "attempted", "unattempted"): |
| 264 | + async_manager.send({"type": "input_audio_buffer.clear", "event_id": identity}) |
| 265 | + async with async_manager as connection: |
| 266 | + if not preopen: |
| 267 | + with pytest.raises(OSError, match="synthetic interruption after send"): |
| 268 | + await connection.send({"type": "input_audio_buffer.clear", "event_id": "attempted"}) |
| 269 | + event = await asyncio.wait_for(anext(aiter(connection)), 5) |
| 270 | + assert event.type == "input_audio_buffer.cleared" |
| 271 | + expected = [(1, "first"), (1, "attempted"), (2, "unattempted"), (2, "during-recovery")] |
| 272 | + assert recorded == (expected if preopen else [(1, "attempted"), (2, "during-recovery")]) |
0 commit comments