codex-backend-sdk 0.5.7__tar.gz → 0.5.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/CHANGELOG.md +26 -0
  2. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/PKG-INFO +19 -1
  3. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/README.md +18 -0
  4. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/__init__.py +1 -1
  5. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/_models.py +2 -2
  6. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/_responses_payloads.py +26 -8
  7. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/chatgpt_plugins.py +2 -4
  8. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/realtime.py +43 -6
  9. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/responses_websocket.py +68 -7
  10. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/docs/api-reference.md +46 -6
  11. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/docs/backend-api.md +20 -4
  12. codex_backend_sdk-0.5.9/docs/codex-source-audit.md +61 -0
  13. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/docs/desktop-endpoint-inventory.md +4 -0
  14. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/docs/endpoint-coverage.md +5 -0
  15. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/docs/openai-compatibility.md +2 -1
  16. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/pyproject.toml +1 -1
  17. codex_backend_sdk-0.5.9/tests/general/test_codex_source_contracts.py +227 -0
  18. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_realtime_resource.py +34 -0
  19. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/uv.lock +1 -1
  20. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/.gitignore +0 -0
  21. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/LICENSE +0 -0
  22. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/_client.py +0 -0
  23. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/_codex_version.py +0 -0
  24. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/_streaming.py +0 -0
  25. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/_transport.py +0 -0
  26. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/_utils.py +0 -0
  27. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/codex_client.py +0 -0
  28. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/oauth.py +0 -0
  29. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/pkce.py +0 -0
  30. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/__init__.py +0 -0
  31. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/_responses_routing.py +0 -0
  32. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/chatgpt.py +0 -0
  33. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/chatgpt_apps.py +0 -0
  34. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/chatgpt_connectors.py +0 -0
  35. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/chatgpt_plugin_bundles.py +0 -0
  36. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/chatgpt_plugin_shares.py +0 -0
  37. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/chatgpt_writing_blocks.py +0 -0
  38. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/codex.py +0 -0
  39. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/codex_web_search.py +0 -0
  40. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/files.py +0 -0
  41. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/images.py +0 -0
  42. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/models.py +0 -0
  43. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  44. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/remote_control.py +0 -0
  45. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/responses.py +0 -0
  46. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/resources/worktree_snapshots.py +0 -0
  47. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/codex_backend_sdk/storage.py +0 -0
  48. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/examples/agent.py +0 -0
  49. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/conftest.py +0 -0
  50. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_account_info.py +0 -0
  51. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_basic.py +0 -0
  52. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_apps.py +0 -0
  53. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_connectors.py +0 -0
  54. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_plugin_bundles.py +0 -0
  55. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_plugin_shares.py +0 -0
  56. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_plugins.py +0 -0
  57. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_projects_gizmos.py +0 -0
  58. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_resources.py +0 -0
  59. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_search_files.py +0 -0
  60. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_chatgpt_writing_blocks.py +0 -0
  61. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_client_retry.py +0 -0
  62. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_codex_resources.py +0 -0
  63. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_codex_version.py +0 -0
  64. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_codex_web_search.py +0 -0
  65. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_conversation.py +0 -0
  66. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_files_resource.py +0 -0
  67. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_hosted_mcp.py +0 -0
  68. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_images.py +0 -0
  69. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_oauth.py +0 -0
  70. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_openai_compatibility.py +0 -0
  71. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_openai_oauth_resources.py +0 -0
  72. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_prompt_cache_usage.py +0 -0
  73. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_reasoning.py +0 -0
  74. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_remote_control_resource.py +0 -0
  75. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_responses_resource.py +0 -0
  76. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_responses_routing.py +0 -0
  77. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_responses_websocket.py +0 -0
  78. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_structured_output.py +0 -0
  79. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_tools.py +0 -0
  80. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.9}/tests/test_worktree_snapshots.py +0 -0
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.5.9] - 2026-10-24
6
+
7
+ ### Added
8
+ - OAuth transcription-only primary Realtime WebSocket with JSON events,
9
+ context-managed cleanup, bounded socket timeout, and no automatic audio replay.
10
+ Verified with synthetic PCM16LE mono 24 kHz speech, no developer API key.
11
+
12
+ ### Documentation
13
+ - Distinguish verified Realtime transcription from the legacy batch route's
14
+ current HTTP 403 responses; OAuth success does not establish quota coverage.
15
+
16
+ ## [0.5.8] - 2026-10-22
17
+
18
+ ### Added
19
+ - Responses WebSocket `interrupt()` with active-response tracking,
20
+ acknowledgement validation, and draining before socket reuse.
21
+ - Source compatibility audit against Codex `0eb98b4617`, with native cloud
22
+ gRPC Resume/Attach explicitly inventoried as unsupported.
23
+
24
+ ### Fixed
25
+ - Preserve message IDs, status, phase, encrypted content, and backend metadata
26
+ when replaying Responses history; retire abandoned WebSocket streams.
27
+ - Serialize numeric custom reasoning effort strings as unsigned 64-bit JSON
28
+ integers and preserve reasoning context on attribute-based controls.
29
+ - Send and validate installed-plugin pagination limits with scoped requests.
30
+
5
31
  ## [0.5.7] - 2026-10-22
6
32
 
7
33
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codex-backend-sdk
3
- Version: 0.5.7
3
+ Version: 0.5.9
4
4
  Summary: Unofficial Python SDK for the ChatGPT Codex backend API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -55,6 +55,12 @@ The package intentionally separates three kinds of API:
55
55
  pip install --upgrade codex-backend-sdk
56
56
  ```
57
57
 
58
+ Version **0.5.8** aligns the SDK with Codex source commit `0eb98b4617`:
59
+ WebSocket interruption, message replay fields, numeric reasoning efforts, and
60
+ scoped installed-plugin pagination. See the [source audit](docs/codex-source-audit.md)
61
+ for verified contracts and the separately inventoried, unsupported cloud gRPC
62
+ transport.
63
+
58
64
  For an editable checkout:
59
65
 
60
66
  ```bash
@@ -185,6 +191,18 @@ with client.responses.websocket.connect() as ws:
185
191
  print(event.get("delta", ""), end="")
186
192
  ```
187
193
 
194
+ To stop an active WebSocket response, call `ws.interrupt()` after receiving
195
+ `response.created`, including from another thread. Keep consuming events until
196
+ both `response.interrupt.accepted` and the terminal response event have arrived
197
+ before reusing the connection. Closing an unfinished event iterator closes the
198
+ connection. Replayed messages retain their IDs, phase, status, encrypted content,
199
+ and backend metadata.
200
+
201
+ Custom numeric reasoning budgets are also supported:
202
+ `reasoning={"effort": 4096, "context": "all_turns"}`. Numeric strings such as
203
+ `"4096"` are encoded as JSON integers, matching Codex; model support is determined
204
+ by the backend.
205
+
188
206
  ### Maintain a multi-turn conversation
189
207
 
190
208
  The Codex backend does not expose `previous_response_id`; preserve prior items
@@ -26,6 +26,12 @@ The package intentionally separates three kinds of API:
26
26
  pip install --upgrade codex-backend-sdk
27
27
  ```
28
28
 
29
+ Version **0.5.8** aligns the SDK with Codex source commit `0eb98b4617`:
30
+ WebSocket interruption, message replay fields, numeric reasoning efforts, and
31
+ scoped installed-plugin pagination. See the [source audit](docs/codex-source-audit.md)
32
+ for verified contracts and the separately inventoried, unsupported cloud gRPC
33
+ transport.
34
+
29
35
  For an editable checkout:
30
36
 
31
37
  ```bash
@@ -156,6 +162,18 @@ with client.responses.websocket.connect() as ws:
156
162
  print(event.get("delta", ""), end="")
157
163
  ```
158
164
 
165
+ To stop an active WebSocket response, call `ws.interrupt()` after receiving
166
+ `response.created`, including from another thread. Keep consuming events until
167
+ both `response.interrupt.accepted` and the terminal response event have arrived
168
+ before reusing the connection. Closing an unfinished event iterator closes the
169
+ connection. Replayed messages retain their IDs, phase, status, encrypted content,
170
+ and backend metadata.
171
+
172
+ Custom numeric reasoning budgets are also supported:
173
+ `reasoning={"effort": 4096, "context": "all_turns"}`. Numeric strings such as
174
+ `"4096"` are encoded as JSON integers, matching Codex; model support is determined
175
+ by the backend.
176
+
159
177
  ### Maintain a multi-turn conversation
160
178
 
161
179
  The Codex backend does not expose `previous_response_id`; preserve prior items
@@ -17,7 +17,7 @@ Quickstart:
17
17
  print(response.output_text)
18
18
  """
19
19
 
20
- __version__ = "0.5.7"
20
+ __version__ = "0.5.9"
21
21
 
22
22
  from .oauth import (
23
23
  DeviceCode,
@@ -7,12 +7,12 @@ import time
7
7
  from collections.abc import Iterator
8
8
  from io import BytesIO
9
9
  from pathlib import Path
10
- from typing import Any, Generic, Literal, Optional, TypeVar
10
+ from typing import Any, Generic, Literal, Optional, TypeVar, Union
11
11
 
12
12
  import requests
13
13
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
14
14
 
15
- ReasoningEffort = Literal["minimal", "low", "medium", "high", "xhigh"]
15
+ ReasoningEffort = Union[Literal["none", "minimal", "low", "medium", "high", "xhigh"], str, int]
16
16
  ReasoningSummary = Literal["concise", "detailed", "auto"]
17
17
  Verbosity = Literal["low", "medium", "high"]
18
18
  ServiceTier = Literal["flex", "priority"]
@@ -16,7 +16,7 @@ from .._models import (
16
16
  ResponseUsage,
17
17
  TokenDetails,
18
18
  )
19
- from .._utils import _UNSET, _default, _is_given, _jsonable
19
+ from .._utils import _default, _is_given, _jsonable
20
20
 
21
21
 
22
22
  class ResponsesCreateRequest(CodexBaseModel):
@@ -169,7 +169,15 @@ def normalize_input_item(item: Any) -> dict[str, Any]:
169
169
  {"type": "input_text", "text": part} if isinstance(part, str) else part
170
170
  for part in content
171
171
  ]
172
- return _message(role, content)
172
+ message = _message(role, content)
173
+ # Preserve backend replay fields while still excluding local harness-only keys.
174
+ for key in (
175
+ "id", "status", "phase", "encrypted_content",
176
+ "internal_chat_message_metadata_passthrough",
177
+ ):
178
+ if key in raw:
179
+ message[key] = raw[key]
180
+ return message
173
181
 
174
182
 
175
183
  def normalize_tools(tools: Any) -> list[dict[str, Any]]:
@@ -181,12 +189,22 @@ def normalize_tools(tools: Any) -> list[dict[str, Any]]:
181
189
  def normalize_reasoning(reasoning: Any) -> dict[str, Any]:
182
190
  reasoning = _jsonable(reasoning)
183
191
  if isinstance(reasoning, dict):
184
- return {key: value for key, value in reasoning.items() if value is not None}
185
- return {
186
- key: value
187
- for key in ("effort", "summary")
188
- if (value := getattr(reasoning, key, None)) is not None
189
- }
192
+ normalized = {key: value for key, value in reasoning.items() if value is not None}
193
+ else:
194
+ normalized = {
195
+ key: value
196
+ for key in ("effort", "summary", "context")
197
+ if (value := getattr(reasoning, key, None)) is not None
198
+ }
199
+ effort = normalized.get("effort")
200
+ # Codex serializes numeric custom efforts as unsigned 64-bit JSON numbers.
201
+ if isinstance(effort, str) and effort.isascii():
202
+ digits = effort.removeprefix("+")
203
+ if digits.isdecimal() and len(digits.lstrip("0")) <= 20:
204
+ numeric = int(digits.lstrip("0") or "0")
205
+ if numeric <= 2**64 - 1:
206
+ normalized["effort"] = numeric
207
+ return normalized
190
208
 
191
209
 
192
210
  def normalize_text(text: Any) -> dict[str, Any]:
@@ -106,10 +106,8 @@ class ChatGPTPlugins:
106
106
  page_token: str | None = None,
107
107
  include_download_urls: bool = False,
108
108
  ) -> dict[str, Any]:
109
- params: dict[str, Any] = {}
110
- if scope is None:
111
- params["limit"] = _positive(limit, "limit")
112
- else:
109
+ params: dict[str, Any] = {"limit": _positive(limit, "limit")}
110
+ if scope is not None:
113
111
  params["scope"] = _scope(scope)
114
112
  if page_token is not None:
115
113
  params["pageToken"] = _required(page_token, "page_token")
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from contextlib import contextmanager
6
7
  import threading
7
8
  import urllib.parse
8
9
  import uuid
@@ -33,6 +34,38 @@ class Realtime:
33
34
  self.sideband = RealtimeSideband(client)
34
35
 
35
36
 
37
+ @contextmanager
38
+ def connect(self, *, intent: str, timeout: Optional[float] = None):
39
+ """Open a transcription-only primary WebSocket with ChatGPT OAuth.
40
+
41
+ Events are JSON dictionaries sent/received with ``send``/``recv``.
42
+ There is no automatic retry or audio replay. Availability and usage
43
+ attribution depend on the backend; subscription coverage is not implied.
44
+ """
45
+ if intent != "transcription":
46
+ raise CodexBackendUnsupportedParameterError(
47
+ "Only intent='transcription' is supported by the OAuth primary socket."
48
+ )
49
+ store = self._client._store
50
+ if store is None or not store.access_token:
51
+ raise RuntimeError("Realtime transcription requires ChatGPT OAuth authentication.")
52
+ headers = {
53
+ "Authorization": f"Bearer {store.access_token}",
54
+ "originator": "codex_cli_rs",
55
+ "x-session-id": str(uuid.uuid4()),
56
+ }
57
+ if store.account_id:
58
+ headers["ChatGPT-Account-ID"] = store.account_id
59
+ socket = websocket.create_connection(
60
+ "wss://api.openai.com/v1/realtime?intent=transcription",
61
+ header=headers,
62
+ timeout=self._client._timeout if timeout is None else timeout,
63
+ redirect_limit=0,
64
+ )
65
+ with RealtimeConnection(socket) as connection:
66
+ yield connection
67
+
68
+
36
69
  class Live:
37
70
  """OpenAI-compatible Live resource backed by ChatGPT OAuth.
38
71
 
@@ -114,8 +147,8 @@ class Live:
114
147
  )
115
148
 
116
149
 
117
- class RealtimeSidebandConnection:
118
- """JSON transport joined to an existing Realtime v3 WebRTC call."""
150
+ class RealtimeConnection:
151
+ """Synchronous JSON event transport with deterministic socket cleanup."""
119
152
 
120
153
  def __init__(self, socket: Any, *, graceful_close: bool = False) -> None:
121
154
  self._socket = socket
@@ -125,20 +158,20 @@ class RealtimeSidebandConnection:
125
158
 
126
159
  def send(self, event: Any) -> None:
127
160
  if self.closed:
128
- raise RuntimeError("Realtime sideband connection is closed.")
161
+ raise RuntimeError("Realtime connection is closed.")
129
162
  payload = json.dumps(_jsonable(event), ensure_ascii=False)
130
163
  with self._send_lock:
131
164
  self._socket.send(payload)
132
165
 
133
166
  def recv(self) -> dict[str, Any]:
134
167
  if self.closed:
135
- raise RuntimeError("Realtime sideband connection is closed.")
168
+ raise RuntimeError("Realtime connection is closed.")
136
169
  payload = self._socket.recv()
137
170
  if isinstance(payload, bytes):
138
171
  payload = payload.decode("utf-8")
139
172
  event = json.loads(payload)
140
173
  if not isinstance(event, dict):
141
- raise TypeError("Expected a Realtime sideband event to be a JSON object.")
174
+ raise TypeError("Expected a Realtime event to be a JSON object.")
142
175
  return event
143
176
 
144
177
  def close(self) -> None:
@@ -159,13 +192,17 @@ class RealtimeSidebandConnection:
159
192
  except (IndexError, StopIteration):
160
193
  return
161
194
 
162
- def __enter__(self) -> "RealtimeSidebandConnection":
195
+ def __enter__(self) -> "RealtimeConnection":
163
196
  return self
164
197
 
165
198
  def __exit__(self, *_: Any) -> None:
166
199
  self.close()
167
200
 
168
201
 
202
+ class RealtimeSidebandConnection(RealtimeConnection):
203
+ """JSON transport joined to an existing Realtime v3 WebRTC call."""
204
+
205
+
169
206
  class LiveSidebandConnection(RealtimeSidebandConnection):
170
207
  """Live-shaped event stream over the Codex OAuth sideband dialect."""
171
208
 
@@ -38,6 +38,10 @@ class ResponsesWebSocketConnection:
38
38
  self._socket = socket
39
39
  self.url = url
40
40
  self._request_lock = threading.Lock()
41
+ self._state_lock = threading.RLock()
42
+ self._response_id: str | None = None
43
+ self._interrupt_requested = False
44
+ self._interrupt_pending = False
41
45
  self.closed = False
42
46
  headers = _handshake_headers(socket)
43
47
  self.reasoning_included = "x-reasoning-included" in headers
@@ -68,32 +72,59 @@ class ResponsesWebSocketConnection:
68
72
  raise RuntimeError(
69
73
  "Responses WebSocket connection already has an active response."
70
74
  )
75
+ finished = False
71
76
  try:
72
- self._socket.send(encoded)
77
+ with self._state_lock:
78
+ self._response_id = None
79
+ self._interrupt_requested = False
80
+ self._interrupt_pending = False
81
+ self._socket.send(encoded)
82
+ terminal = False
73
83
  while True:
74
84
  raw = self._socket.recv()
75
85
  if raw in {None, "", b""}:
76
- self.closed = True
77
86
  raise RuntimeError(
78
87
  "Responses WebSocket closed before a terminal response event."
79
88
  )
80
89
  if isinstance(raw, bytes):
81
- self.closed = True
82
90
  raise RuntimeError("Responses WebSocket returned unexpected binary data.")
83
91
  try:
84
92
  event = json.loads(raw)
85
93
  except (TypeError, json.JSONDecodeError) as exc:
86
- self.closed = True
87
94
  raise RuntimeError("Responses WebSocket returned invalid JSON.") from exc
88
95
  if not isinstance(event, dict) or not isinstance(event.get("type"), str):
89
- self.closed = True
90
96
  raise RuntimeError(
91
97
  "Responses WebSocket returned an invalid event envelope."
92
98
  )
93
99
  if event["type"] == "error":
94
100
  raise _event_error(event)
101
+ with self._state_lock:
102
+ if event["type"] == "response.created":
103
+ response = event.get("response")
104
+ response_id = response.get("id") if isinstance(response, dict) else None
105
+ if isinstance(response_id, str) and response_id:
106
+ self._response_id = response_id
107
+ elif event["type"] in {
108
+ "response.interrupt.accepted", "response.interrupt.failed"
109
+ }:
110
+ if (
111
+ not self._interrupt_pending
112
+ or event.get("response_id") != self._response_id
113
+ ):
114
+ raise RuntimeError("Unexpected response interruption acknowledgement.")
115
+ if event["type"] == "response.interrupt.failed":
116
+ raise RuntimeError("Response interruption was rejected.")
117
+ self._interrupt_pending = False
118
+ if event["type"] in _TERMINAL_EVENTS:
119
+ terminal = True
120
+ # Prevent a late interrupt after completion while draining its ack.
121
+ self._interrupt_requested = True
122
+ done = terminal and not self._interrupt_pending
123
+ if done:
124
+ self._response_id = None
125
+ finished = True
95
126
  yield event
96
- if event["type"] in _TERMINAL_EVENTS:
127
+ if done:
97
128
  return
98
129
  except Exception:
99
130
  if not self.closed:
@@ -103,7 +134,37 @@ class ResponsesWebSocketConnection:
103
134
  self.closed = True
104
135
  raise
105
136
  finally:
106
- self._request_lock.release()
137
+ try:
138
+ with self._state_lock:
139
+ self._response_id = None
140
+ if not finished:
141
+ self.close()
142
+ finally:
143
+ self._request_lock.release()
144
+
145
+ def interrupt(self) -> None:
146
+ """Interrupt the active response, discarding partial items.
147
+
148
+ Call after ``response.created``, including from another thread while
149
+ reading events. Continue consuming through both the acknowledgement
150
+ and terminal response event before reusing the connection.
151
+ """
152
+ with self._state_lock:
153
+ if self.closed:
154
+ raise RuntimeError("Responses WebSocket connection is closed.")
155
+ if self._response_id is None or self._interrupt_requested:
156
+ raise RuntimeError("No active response is available to interrupt.")
157
+ self._interrupt_requested = True
158
+ self._interrupt_pending = True
159
+ try:
160
+ self._socket.send(json.dumps({
161
+ "type": "response.interrupt",
162
+ "response_id": self._response_id,
163
+ "mode": "discard_partial_items",
164
+ }, separators=(",", ":")))
165
+ except Exception:
166
+ self.close()
167
+ raise
107
168
 
108
169
  def create(self, request: Mapping[str, Any]) -> Iterator[dict[str, Any]]:
109
170
  """Alias for :meth:`events` using Responses resource terminology."""
@@ -110,6 +110,13 @@ Supported backend fields are `input`, `model`, `instructions`, `include`,
110
110
  iterator; otherwise the SDK collects the mandatory backend SSE stream into a
111
111
  typed `Response`.
112
112
 
113
+ `reasoning` dictionaries preserve additive backend controls such as
114
+ `context="all_turns"`. Numeric custom effort strings (for example `"4096"`)
115
+ are serialized as unsigned 64-bit JSON integers, matching Codex; named efforts
116
+ remain strings. This also applies to compaction. Message history retains
117
+ backend replay fields (`id`, `status`, `phase`, `encrypted_content`, and
118
+ `internal_chat_message_metadata_passthrough`) and content-part metadata.
119
+
113
120
  For HTTP `create`, `parse` and `compact` requests, a nonempty `prompt_cache_key`
114
121
  also supplies the `session-id` header used by the ChatGPT backend for cache
115
122
  affinity. An explicit `session-id` in `extra_headers` takes precedence
@@ -165,11 +172,20 @@ Connection methods:
165
172
  | --- | --- | --- |
166
173
  | `create(request)` | raw Responses request mapping | iterator of raw event dictionaries |
167
174
  | `events(request)` | raw `response.create` event or request mapping | iterator through terminal completion/error |
175
+ | `interrupt()` | none; active response must have emitted `response.created` | sends `response.interrupt` with `discard_partial_items` |
168
176
  | `close()` | none | `None` |
169
177
 
170
178
  The connection is a context manager and is reusable for sequential, not
171
179
  concurrent, turns. Backend error envelopes raise `ResponsesWebSocketError` with
172
- `code`, `message`, `event`, and optional `param`.
180
+ `code`, the exception message, `event`, and optional `status`.
181
+
182
+ After `interrupt()`, keep consuming events through both
183
+ `response.interrupt.accepted` and the terminal response event. They may arrive
184
+ in either order. The acknowledgement must match the active response ID;
185
+ rejection or an invalid acknowledgement closes the connection. You can call
186
+ `interrupt()` from another thread while consuming the stream. Abandoning an
187
+ unfinished event iterator also closes the connection to prevent unread events
188
+ from leaking into the next request.
173
189
 
174
190
  ### Models
175
191
 
@@ -234,7 +250,8 @@ content and a MIME type.
234
250
  Required: `file`, `model`. Supported optional backend parameters are `language`,
235
251
  `prompt`, `response_format` (`json` or `text`), `temperature`, `extra_headers`,
236
252
  `extra_query`, `extra_body`, and `timeout`. Returns typed `Transcription` for
237
- JSON or `str` for text.
253
+ JSON or `str` for text. The legacy batch route returned HTTP 403 in live tests
254
+ on October 24, 2026; prefer the verified transcription WebSocket below.
238
255
 
239
256
  The OpenAI-compatible signature rejects unsupported `chunking_strategy`,
240
257
  `include`, `known_speaker_names`, `known_speaker_references`, `stream`, and
@@ -255,6 +272,7 @@ Platform quota rather than the ChatGPT subscription.
255
272
 
256
273
  | Method | Parameters | Returns |
257
274
  | --- | --- | --- |
275
+ | `client.realtime.connect(...)` | `intent="transcription"`, optional `timeout` | context manager yielding a JSON `RealtimeConnection` |
258
276
  | `client.live.create(...)` | official `session`, `transport`; standard request extensions | `LiveCreateResponse` with `session.id` and `transport.sdp` |
259
277
  | `client.live.sideband.connect(...)` | official sideband keywords including `session_id`, `graceful_close`, request/socket/retry options | lazy `LiveSidebandConnectionManager` context manager |
260
278
  | `client.realtime.calls.create_v3(...)` | `sdp`, `session`, optional `session_id`, `thread_id`, transport extensions | typed `RealtimeCallResponse` |
@@ -268,11 +286,11 @@ apply `response.transport.sdp`, then attach with
268
286
  adapter presents the backend call ID as the Live session ID. The connection is
269
287
  iterable and yields forward-compatible `LiveEvent` objects with attribute
270
288
  access. The public primary `client.live.connect()` transport raises an explicit
271
- unsupported error because ChatGPT OAuth currently exposes only WebRTC plus an
272
- attached sideband.
289
+ unsupported error. This is distinct from the transcription-only primary
290
+ `client.realtime.connect(intent="transcription")` socket, verified with OAuth
291
+ on October 24, 2026. Its quota/billing attribution is not established.
273
292
 
274
- `create_v3()` requires the effective model to be `gpt-live-1-codex` or
275
- `gpt-live-1-boulder-alpha`, removes any caller-supplied session ID, generates
293
+ `create_v3()` forwards the requested model and explicit correlation IDs, generates
276
294
  required backend correlation IDs when omitted, and applies the current Codex v3
277
295
  intent/architecture. The result exposes `answer_sdp`, `call_id`, raw `session`,
278
296
  and original response text. The sideband connection joins that call over
@@ -752,3 +770,25 @@ validation errors according to the boundary.
752
770
  mutations.
753
771
  - Commercial/admin, attestation, reporting, telemetry, analytics, beacon, and
754
772
  personal-access-token surfaces are outside the public SDK API.
773
+
774
+ ### Transcription-only OAuth WebSocket
775
+
776
+ ```python
777
+ client.authenticate(interactive=False)
778
+ with client.realtime.connect(intent="transcription", timeout=10) as connection:
779
+ connection.send({"type": "session.update", "session": {
780
+ "type": "transcription", "audio": {"input": {
781
+ "format": {"type": "audio/pcm", "rate": 24000},
782
+ "transcription": {"model": "gpt-4o-mini-transcribe", "language": "fr"},
783
+ "turn_detection": None,
784
+ }},
785
+ }})
786
+ # Wait for session.updated (handle error events), then send base64 PCM16LE
787
+ # mono chunks as input_audio_buffer.append and input_audio_buffer.commit.
788
+ # Receive conversation.item.input_audio_transcription.completed.
789
+ ```
790
+
791
+ This synchronous raw-event surface has no automatic retry/replay, generated
792
+ assistant response, tools, or output audio. Set a bounded socket timeout and an
793
+ application-level total deadline. Context exit closes the socket even on failure.
794
+ Only `intent="transcription"` is supported; model/session events remain caller-owned.
@@ -402,7 +402,8 @@ low-level `client.realtime.sideband.connect(...)`) joins its
402
402
  JSON control/delegation channel at `/v1/live/{call_id}` with the current ChatGPT
403
403
  OAuth account. This transport is used for client-managed delegation events and
404
404
  context appends. The SDK intentionally does not expose the unrelated API-key
405
- Voice v2 WebSocket surface.
405
+ Voice v2 conversation WebSocket surface. It separately exposes the verified
406
+ transcription-only primary socket described below.
406
407
 
407
408
  ---
408
409
 
@@ -410,8 +411,10 @@ Voice v2 WebSocket surface.
410
411
 
411
412
  These OpenAI-shaped resources deliberately use different upstreams. Embeddings
412
413
  remain an OpenAI Platform call and consume the associated developer-account
413
- quota. Batch transcription uses the authenticated ChatGPT backend and does not
414
- require a developer API key.
414
+ quota. Legacy batch transcription uses the authenticated ChatGPT backend but
415
+ returned HTTP 403 in current live tests. The transcription-only Realtime socket
416
+ accepts ChatGPT OAuth without a developer API key; its quota/billing attribution
417
+ is not established.
415
418
 
416
419
  ### `POST /v1/embeddings`
417
420
 
@@ -437,13 +440,26 @@ subscription.
437
440
 
438
441
  **SDK method**: `client.audio.transcriptions.create(...)`
439
442
 
440
- **Status**: Supported for non-streaming ChatGPT batch transcription. The SDK
443
+ **Status**: Legacy adapter present, but live tests with WebM and WAV returned
444
+ HTTP 403 HTML on October 24, 2026. This is not proof of permanent removal. The SDK
441
445
  uploads multipart audio with the OAuth bearer and `ChatGPT-Account-ID`, and
442
446
  supports the `model`, `language`, `prompt`, `temperature`, and `json`/`text`
443
447
  response options used by Codex Agent. Streaming, timestamps, speaker references,
444
448
  chunking, SRT, and VTT are rejected locally rather than falling back to a
445
449
  billable Platform endpoint.
446
450
 
451
+ ### `WSS /v1/realtime?intent=transcription` (api.openai.com)
452
+
453
+ **SDK method**: `client.realtime.connect(intent="transcription", timeout=...)`
454
+
455
+ **Status**: Verified October 24, 2026 with a ChatGPT OAuth bearer, no API key.
456
+ `session.update` with type `transcription`, PCM16LE mono 24 kHz,
457
+ `gpt-4o-mini-transcribe`, and `turn_detection: null` accepted synthetic speech and
458
+ returned the correct `conversation.item.input_audio_transcription.completed` text.
459
+ The ChatGPT backend URL `/backend-api/codex/realtime?intent=transcription` instead
460
+ returned HTTP 403. Subscription coverage and quota/billing attribution remain
461
+ unverified; successful OAuth authentication alone does not establish either.
462
+
447
463
  ### `POST /backend-api/codex/images/generations`
448
464
 
449
465
  **SDK method**: `client.images.generate(...)`
@@ -0,0 +1,61 @@
1
+ # Codex source compatibility audit — 2026-10-22
2
+
3
+ Compared `openai/codex` from the previously audited
4
+ `7498521d288b9b3b96ffba4eedf089d8d6e06a84` to
5
+ `0eb98b46176fd41fc285e5e2f82218bf0ccd70f5` (`origin/main`). The local checkout
6
+ was updated by fast-forward and has no local changes. This is a source-contract
7
+ audit; the packaged Desktop ASAR was not rescanned and no live backend requests
8
+ were used to validate the new contracts.
9
+
10
+ ## Scope and method
11
+
12
+ Reviewed changed production contracts in `codex-rs/codex-api`, `backend-client`,
13
+ `core-plugins` remote catalog/search/sharing, `chatgpt` connectors,
14
+ `app-server-transport` Remote Control, `login`, cloud tasks, and `cloud-client`.
15
+ Searched changed Rust production sources across `codex-rs` for added backend
16
+ route literals and checked request builders, response types, and protocol item
17
+ changes. Local app-server JSON-RPC methods, CLI flags, and harness policy are
18
+ outside this HTTP backend SDK's scope.
19
+
20
+ ## Findings and disposition
21
+
22
+ | Upstream change | SDK disposition and verification |
23
+ | --- | --- |
24
+ | WebSocket `response.interrupt` with `response_id` and `mode: discard_partial_items` | Added `connection.interrupt()`. Tracks the active response, checks the acknowledgement ID, rejects failed/unsolicited acknowledgements, and drains both acknowledgement and terminal event before reuse. Closing an unfinished iterator retires the socket. Tests cover cross-thread interruption and completion arriving before acknowledgement. |
25
+ | Numeric custom reasoning effort serialized as an unsigned 64-bit JSON number | HTTP Responses and compaction convert parseable numeric effort strings into integers, matching Codex. Existing numbers, named/custom string efforts, and additive dictionary options remain supported. Attribute-based controls retain `context`. Exported `ReasoningEffort` accepts strings and integers. |
26
+ | Messages gain `encrypted_content` and `status`; `phase` gains `partial_answer`; output text gains `annotations`/`logprobs` | Message normalization now retains known replay fields, including `id`, `phase`, and metadata. Content parts and non-message items remain raw. A round-trip test protects opaque fields, empty arrays, `additional_tools`, and `configuration_update`. Local `kind`/`call_id` keys on ordinary messages remain filtered. |
27
+ | Installed-plugin pagination always sends `limit`, including scoped requests | Fixed `.plugins.installed(scope=..., limit=...)`; validates and transmits the limit alongside the scope and cursor. Aggregation already rejects repeated cursors. |
28
+ | Image edit references allow both `image_url` and `file_id` | Already supported; mixed references are contract-tested. HTTP failures retain the underlying response and headers, including nested ImageGen request IDs. |
29
+ | Thread usage and groups gain `native_usage_usd_micros`; plugin release gains `onboarding_skill_name`; new plan names | Existing raw dictionaries preserve additive fields. Contract test distinguishes absent/null/zero/native amounts without estimating them. |
30
+ | Base instructions move from the top-level field into developer input messages (`c9253c4977`) | Developer messages are supported, and their explicit IDs/metadata now survive normalization. The OpenAI-shaped `instructions=` argument keeps its existing top-level encoding for compatibility; removing it from Codex's own request struct does not establish backend rejection. No claim of live equivalence or retirement of the old field is made. |
31
+ | Responses serializes `model`, `stream`, `service_tier` before large input (`ed0cc1a4ab`) | Values already supported; ordering is an upstream gateway optimization, not a newly required parameter. |
32
+ | Provider-specific model catalog URLs and plugin product SKU become configurable | Provider/gateway configuration is outside the SDK's fixed ChatGPT backend routing. Catalog query overrides and plugin `codex` product headers remain supported. |
33
+ | Realtime transcript handling, shared HTTP/network policy, proxy fallback, file PUT retries, OAuth storage/provenance changes | Existing endpoint paths and request schemas remain compatible. These client implementation/policy changes do not add required backend parameters. Realtime events and WebSocket envelopes remain raw. |
34
+ | Native cloud ThreadService `Resume` and live `Attach` | **Inventory, unsupported.** Separate HTTP/2 gRPC/protobuf transport, not an HTTP JSON endpoint or app-server JSON-RPC. Upstream requires a caller-supplied trusted origin and explicitly does not establish production ingress. Do not guess a URL or send these payloads through the SDK's JSON transport. |
35
+
36
+ Relevant source locations at the audited commit:
37
+
38
+ - `codex-rs/codex-api/src/common.rs`
39
+ - `codex-rs/codex-api/src/endpoint/responses_websocket.rs`
40
+ - `codex-rs/core/src/client.rs` and `core/src/context/base_instructions.rs`
41
+ - `codex-rs/protocol/src/models.rs`
42
+ - `codex-rs/core-plugins/src/remote.rs`
43
+ - `codex-rs/backend-client/src/client/thread_usage.rs`
44
+ - `codex-rs/cloud-client/README.md`, `src/lib.rs`, and `src/wire.rs`
45
+
46
+ No new HTTP route in the reviewed backend families requires a new SDK resource.
47
+ The native gRPC surface is explicitly tracked above. Earlier Desktop-only
48
+ coverage and exclusions remain as recorded in `desktop-endpoint-inventory.md`.
49
+
50
+ ## Validation
51
+
52
+ Durable behavioral contracts added by this audit live in
53
+ `tests/general/test_codex_source_contracts.py`. Existing local contract tests
54
+ are also run; tests using the authenticated `client` fixture are excluded from
55
+ the local-only run. No transient diagnostic tests are retained in
56
+ `tests/agentic/`, and no backend mutation or inference is performed by these tests.
57
+
58
+ Result: **310 passed, 12 live tests deselected**. Ruff's correctness rules
59
+ (`E4`, `E7`, `E9`, `F`, Python 3.9 target) and `git diff --check` also pass for
60
+ the changed Python files. The broader Ruff style rules report existing issues
61
+ in these modules and are not a clean project baseline.
@@ -5,6 +5,10 @@ routes referenced by the official Codex Desktop application but not necessarily
5
5
  by `codex-rs`. It is an implementation survey, not a stability guarantee or an
6
6
  assertion that every route is enabled for every account.
7
7
 
8
+ For the later Codex checkout update through `0eb98b4617`, see the
9
+ [2026-10-22 source audit](codex-source-audit.md). That audit does not replace or
10
+ refresh the packaged Desktop snapshot recorded below.
11
+
8
12
  ## Audited snapshot and method
9
13
 
10
14
  - Codex checkout: `openai/codex` at
@@ -4,6 +4,11 @@ This is the living implementation matrix for backend surfaces found in the
4
4
  official Codex and Codex Desktop clients. The source snapshot and discovery
5
5
  method are recorded in `desktop-endpoint-inventory.md`.
6
6
 
7
+ The subsequent [2026-10-22 Codex source audit](codex-source-audit.md) records
8
+ compatibility through `0eb98b46176fd41fc285e5e2f82218bf0ccd70f5`, including
9
+ WebSocket interruption, message replay fields, numeric reasoning, scoped plugin
10
+ pagination, and the separately inventoried native cloud gRPC transport.
11
+
7
12
  Statuses:
8
13
 
9
14
  - **live**: exercised against the backend with Codex OAuth;
@@ -20,7 +20,8 @@ parameter cannot disappear silently from the backend client surface.
20
20
  | `responses.compact` | Official parameters preserved; Codex tool/reasoning controls are additive | Supported request options and `extra_body` are forwarded | Stored-response continuation and explicit Platform prompt-cache controls are unavailable |
21
21
  | `models.list` / `retrieve` | Official parameters preserved; `force_refresh` is additive | Returns iterable OpenAI-shaped model pages | Catalog retrieval is a Codex endpoint and `retrieve` resolves from that catalog |
22
22
  | `embeddings.create` | Official signature preserved | Uses the OpenAI endpoint with the Codex OAuth bearer | Availability depends on OAuth scopes and account rollout |
23
- | `audio.transcriptions.create` | Official signature preserved | File inputs, JSON/text output, language, prompt, and temperature are supported | Streaming, diarization, timestamps, `keywords`, and multi-language hints raise explicitly |
23
+ | `realtime.connect` | Transcription-only OAuth subset | Context manager, raw JSON send/recv, deterministic close | Only `intent="transcription"`; no automatic replay; quota attribution unverified |
24
+ | `audio.transcriptions.create` | Official signature preserved | Legacy batch adapter retains file inputs and options; live route returned HTTP 403 on October 24, 2026 | Streaming, diarization, timestamps, `keywords`, and multi-language hints raise explicitly |
24
25
  | `images.generate` | Official signature preserved | GPT Image generation returns OpenAI-shaped base64 results | Codex always returns non-streamed PNG/base64 output; format/compression and Platform user attribution are unavailable |
25
26
  | `images.edit` | Official signature preserved | Single/multiple file inputs, URLs, data URLs, reference objects, and masks are normalized to Codex JSON | A mask targets the first image; `file_id` is contract-derived but not live-verified; Codex returns non-streamed PNG/base64 output |
26
27
  | `files.create` | Official signature is present | Not callable with Codex OAuth | Live probe returned missing `api.files.write`; the method raises a scope-specific unsupported error |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-backend-sdk"
7
- version = "0.5.7"
7
+ version = "0.5.9"
8
8
  description = "Unofficial Python SDK for the ChatGPT Codex backend API"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,227 @@
1
+ """Durable wire guarantees for evolving Codex backend contracts."""
2
+
3
+ import json
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from queue import Queue
6
+ from threading import Event
7
+ from types import SimpleNamespace
8
+
9
+ import pytest
10
+
11
+ from codex_backend_sdk import OpenAI, ResponsesWebSocketConnection
12
+
13
+
14
+ class CapturingClient(OpenAI):
15
+ def __init__(self):
16
+ super().__init__(model="test-model")
17
+ self.calls = []
18
+
19
+ def _post(self, path, **kwargs):
20
+ self.calls.append((path, kwargs))
21
+ return SimpleNamespace(
22
+ iter_lines=lambda: iter([
23
+ b'data: {"type":"response.completed","response":{"id":"r1"}}', b""
24
+ ]),
25
+ close=lambda: None,
26
+ )
27
+
28
+ def _post_raw(self, path, **kwargs):
29
+ self.calls.append((path, kwargs))
30
+ return SimpleNamespace(json=lambda: {"created": 1, "data": []})
31
+
32
+ def _get_chatgpt(self, path, **kwargs):
33
+ self.calls.append((path, kwargs))
34
+ return {"plugins": [], "pagination": {"nextPageToken": None}}
35
+
36
+ def _post_wham(self, path, **kwargs):
37
+ self.calls.append((path, kwargs))
38
+ return {"threads": [{
39
+ "thread_id": "t1", "native_usage_usd_micros": None,
40
+ "groups": [{}, {"native_usage_usd_micros": 0}, {"native_usage_usd_micros": 250}],
41
+ }]}
42
+
43
+
44
+ @pytest.mark.parametrize("effort,expected", [
45
+ (4096, 4096), ("4096", 4096), ("+4096", 4096), ("000", 0),
46
+ ("18446744073709551615", 2**64 - 1),
47
+ ("18446744073709551616", "18446744073709551616"),
48
+ ("high", "high"), ("custom-budget", "custom-budget"), ("-1", "-1"),
49
+ ])
50
+ @pytest.mark.parametrize("compact", [False, True])
51
+ def test_numeric_reasoning_and_context_reach_both_http_transports(effort, expected, compact):
52
+ client = CapturingClient()
53
+ reasoning = {"effort": effort, "context": "all_turns", "future_option": True}
54
+ if compact:
55
+ # Compaction needs an opaque result; inspect the request even if the fixture has none.
56
+ with pytest.raises(RuntimeError):
57
+ client.responses.compact(input=[], reasoning=reasoning)
58
+ else:
59
+ client.responses.create(input="hello", reasoning=reasoning)
60
+ assert client.calls[0][1]["body"]["reasoning"] == {
61
+ "effort": expected, "context": "all_turns", "future_option": True,
62
+ }
63
+ assert reasoning["effort"] == effort
64
+
65
+
66
+ def test_reasoning_attribute_objects_preserve_context():
67
+ client = CapturingClient()
68
+ client.responses.create(
69
+ input="hello", reasoning=SimpleNamespace(effort="8192", context="current_turn")
70
+ )
71
+ assert client.calls[0][1]["body"]["reasoning"] == {
72
+ "effort": 8192, "context": "current_turn",
73
+ }
74
+
75
+
76
+ def test_response_replay_preserves_opaque_message_fields_and_new_item_types():
77
+ client = CapturingClient()
78
+ items = [
79
+ {"type": "message", "id": "m1", "role": "developer", "content": [],
80
+ "internal_chat_message_metadata_passthrough": {
81
+ "content_item_kinds": ["model.base_instructions"],
82
+ }},
83
+ {"type": "message", "id": "m2", "role": "assistant", "status": "completed",
84
+ "phase": "partial_answer", "encrypted_content": "opaque", "content": [
85
+ {"type": "output_text", "text": "hello", "annotations": [], "logprobs": []},
86
+ ]},
87
+ {"type": "additional_tools", "role": "developer", "tools": []},
88
+ {"type": "configuration_update", "reasoning": {"effort": 4096}},
89
+ ]
90
+ client.responses.create(input=items)
91
+ assert client.calls[0][1]["body"]["input"] == items
92
+
93
+
94
+ def test_installed_plugins_scope_keeps_limit_and_cursor():
95
+ client = CapturingClient()
96
+ client.chatgpt.plugins.installed(scope="WORKSPACE", limit=7, page_token="next")
97
+ assert client.calls[0][1]["params"] == {
98
+ "scope": "WORKSPACE", "limit": 7, "pageToken": "next",
99
+ }
100
+ with pytest.raises(ValueError):
101
+ client.chatgpt.plugins.installed(scope="WORKSPACE", limit=0)
102
+
103
+
104
+ def test_image_file_references_and_native_usage_are_preserved():
105
+ client = CapturingClient()
106
+ images = [{"image_url": "data:image/png;base64,Zm9v"}, {"file_id": "file-image"}]
107
+ client.images.edit(image=images, prompt="add a red hat")
108
+ assert client.calls[0][1]["body"]["images"] == images
109
+ assert client.codex.usage_details.threads(["t1"]) == {"threads": [{
110
+ "thread_id": "t1", "native_usage_usd_micros": None,
111
+ "groups": [{}, {"native_usage_usd_micros": 0}, {"native_usage_usd_micros": 250}],
112
+ }]}
113
+
114
+
115
+ class Socket:
116
+ def __init__(self, events):
117
+ self.events = iter(events)
118
+ self.sent = []
119
+ self.closed = False
120
+
121
+ def send(self, message):
122
+ self.sent.append(json.loads(message))
123
+
124
+ def recv(self):
125
+ event = next(self.events, None)
126
+ return "" if event is None else json.dumps(event)
127
+
128
+ def close(self):
129
+ self.closed = True
130
+
131
+
132
+ @pytest.mark.parametrize("ack_first", [True, False])
133
+ def test_interrupt_drains_ack_and_completion_before_socket_reuse(ack_first):
134
+ ack = {"type": "response.interrupt.accepted", "response_id": "r1"}
135
+ terminal = {"type": "response.completed", "response": {"id": "r1"}}
136
+ socket = Socket([
137
+ {"type": "response.created", "response": {"id": "r1"}},
138
+ *([ack, terminal] if ack_first else [terminal, ack]),
139
+ {"type": "response.completed", "response": {"id": "r2"}},
140
+ ])
141
+ connection = ResponsesWebSocketConnection(socket, url="wss://example.test")
142
+ with pytest.raises(RuntimeError, match="No active"):
143
+ connection.interrupt()
144
+ stream = connection.events({"model": "test-model"})
145
+ next(stream)
146
+ connection.interrupt()
147
+ assert socket.sent[-1] == {
148
+ "type": "response.interrupt", "response_id": "r1", "mode": "discard_partial_items",
149
+ }
150
+ with pytest.raises(RuntimeError, match="No active"):
151
+ connection.interrupt()
152
+ assert len(list(stream)) == 2
153
+ second = list(connection.events({"model": "test-model"}))
154
+ assert second[0]["response"]["id"] == "r2"
155
+ assert not socket.closed
156
+
157
+
158
+ @pytest.mark.parametrize("ack", [
159
+ {"type": "response.interrupt.accepted", "response_id": "wrong"},
160
+ {"type": "response.interrupt.failed", "response_id": "r1"},
161
+ {"type": "response.interrupt.accepted"},
162
+ ])
163
+ def test_invalid_interrupt_ack_retires_socket(ack):
164
+ socket = Socket([{"type": "response.created", "response": {"id": "r1"}}, ack])
165
+ connection = ResponsesWebSocketConnection(socket, url="wss://example.test")
166
+ stream = connection.events({"model": "test-model"})
167
+ next(stream)
168
+ connection.interrupt()
169
+ with pytest.raises(RuntimeError, match="interruption"):
170
+ list(stream)
171
+ assert socket.closed and connection.closed
172
+
173
+
174
+ def test_abandoned_stream_cannot_reuse_socket_with_unread_events():
175
+ socket = Socket([{"type": "response.created", "response": {"id": "r1"}}])
176
+ connection = ResponsesWebSocketConnection(socket, url="wss://example.test")
177
+ stream = connection.events({"model": "test-model"})
178
+ next(stream)
179
+ stream.close()
180
+ assert socket.closed and connection.closed
181
+
182
+
183
+ @pytest.mark.parametrize("remaining", [
184
+ [],
185
+ [{"type": "response.completed", "response": {"id": "r1"}}],
186
+ [{"type": "response.interrupt.accepted", "response_id": "r1"}],
187
+ ])
188
+ def test_incomplete_interrupt_drain_closes_socket(remaining):
189
+ socket = Socket([{"type": "response.created", "response": {"id": "r1"}}, *remaining])
190
+ connection = ResponsesWebSocketConnection(socket, url="wss://example.test")
191
+ stream = connection.events({"model": "test-model"})
192
+ next(stream)
193
+ connection.interrupt()
194
+ with pytest.raises(RuntimeError, match="closed before"):
195
+ list(stream)
196
+ assert socket.closed and connection.closed
197
+
198
+
199
+ def test_interrupt_from_another_thread_while_receiving():
200
+ ready = Event()
201
+ incoming = Queue()
202
+ incoming.put({"type": "response.created", "response": {"id": "r1"}})
203
+
204
+ class BlockingSocket(Socket):
205
+ def recv(self):
206
+ if len(self.sent) == 1 and incoming.empty():
207
+ ready.set()
208
+ return json.dumps(incoming.get(timeout=3))
209
+
210
+ def send(self, message):
211
+ super().send(message)
212
+ if self.sent[-1]["type"] == "response.interrupt":
213
+ incoming.put({"type": "response.interrupt.accepted", "response_id": "r1"})
214
+ incoming.put({"type": "response.completed", "response": {"id": "r1"}})
215
+
216
+ socket = BlockingSocket([])
217
+ connection = ResponsesWebSocketConnection(socket, url="wss://example.test")
218
+ with ThreadPoolExecutor(max_workers=1) as executor:
219
+ result = executor.submit(lambda: list(connection.events({"model": "test-model"})))
220
+ try:
221
+ assert ready.wait(timeout=3)
222
+ connection.interrupt()
223
+ assert [event["type"] for event in result.result(timeout=3)] == [
224
+ "response.created", "response.interrupt.accepted", "response.completed",
225
+ ]
226
+ finally:
227
+ connection.close()
@@ -279,3 +279,37 @@ def test_realtime_call_response_requires_valid_location():
279
279
  assert "does not contain a valid call id" in str(exc)
280
280
  else:
281
281
  raise AssertionError("Expected invalid Location to fail")
282
+
283
+
284
+ def test_primary_transcription_socket_uses_oauth_and_closes_on_failure(monkeypatch):
285
+ client = FakeRealtimeClient()
286
+ socket = FakeWebSocket([json.dumps({"type": "session.created"})])
287
+ captured = {}
288
+ def connect(url, **kwargs):
289
+ captured.update(url=url, **kwargs)
290
+ return socket
291
+ monkeypatch.setattr("codex_backend_sdk.resources.realtime.websocket.create_connection", connect)
292
+ with pytest.raises(ValueError, match="consumer"):
293
+ with client.realtime.connect(intent="transcription", timeout=12) as connection:
294
+ assert connection.recv() == {"type": "session.created"}
295
+ connection.send({"type": "input_audio_buffer.commit"})
296
+ raise ValueError("consumer")
297
+ assert captured["url"] == "wss://api.openai.com/v1/realtime?intent=transcription"
298
+ assert captured["header"]["Authorization"] == "Bearer chatgpt-token"
299
+ assert captured["header"]["ChatGPT-Account-ID"] == "account-id"
300
+ assert captured["header"]["x-session-id"]
301
+ assert captured["timeout"] == 12
302
+ assert captured["redirect_limit"] == 0
303
+ assert json.loads(socket.sent[0]) == {"type": "input_audio_buffer.commit"}
304
+ assert socket.closed
305
+
306
+
307
+ def test_primary_socket_rejects_other_intents_and_missing_oauth():
308
+ client = FakeRealtimeClient()
309
+ with pytest.raises(NotImplementedError):
310
+ with client.realtime.connect(intent="realtime"):
311
+ pass
312
+ client._store = None
313
+ with pytest.raises(RuntimeError, match="ChatGPT OAuth"):
314
+ with client.realtime.connect(intent="transcription"):
315
+ pass
@@ -162,7 +162,7 @@ wheels = [
162
162
 
163
163
  [[package]]
164
164
  name = "codex-backend-sdk"
165
- version = "0.5.7"
165
+ version = "0.5.9"
166
166
  source = { editable = "." }
167
167
  dependencies = [
168
168
  { name = "pydantic" },