codex-backend-sdk 0.5.7__tar.gz → 0.5.8__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.8}/CHANGELOG.md +15 -0
  2. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/PKG-INFO +19 -1
  3. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/README.md +18 -0
  4. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/__init__.py +1 -1
  5. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_models.py +2 -2
  6. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/_responses_payloads.py +26 -8
  7. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugins.py +2 -4
  8. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/responses_websocket.py +68 -7
  9. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/docs/api-reference.md +17 -1
  10. codex_backend_sdk-0.5.8/docs/codex-source-audit.md +61 -0
  11. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/docs/desktop-endpoint-inventory.md +4 -0
  12. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/docs/endpoint-coverage.md +5 -0
  13. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/pyproject.toml +1 -1
  14. codex_backend_sdk-0.5.8/tests/general/test_codex_source_contracts.py +227 -0
  15. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/uv.lock +1 -1
  16. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/.gitignore +0 -0
  17. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/LICENSE +0 -0
  18. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_client.py +0 -0
  19. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_codex_version.py +0 -0
  20. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_streaming.py +0 -0
  21. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_transport.py +0 -0
  22. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_utils.py +0 -0
  23. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/codex_client.py +0 -0
  24. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/oauth.py +0 -0
  25. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/pkce.py +0 -0
  26. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/__init__.py +0 -0
  27. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/_responses_routing.py +0 -0
  28. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt.py +0 -0
  29. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_apps.py +0 -0
  30. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_connectors.py +0 -0
  31. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugin_bundles.py +0 -0
  32. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugin_shares.py +0 -0
  33. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_writing_blocks.py +0 -0
  34. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/codex.py +0 -0
  35. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/codex_web_search.py +0 -0
  36. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/files.py +0 -0
  37. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/images.py +0 -0
  38. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/models.py +0 -0
  39. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  40. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/realtime.py +0 -0
  41. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/remote_control.py +0 -0
  42. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/responses.py +0 -0
  43. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/worktree_snapshots.py +0 -0
  44. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/codex_backend_sdk/storage.py +0 -0
  45. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/docs/backend-api.md +0 -0
  46. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/docs/openai-compatibility.md +0 -0
  47. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/examples/agent.py +0 -0
  48. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/conftest.py +0 -0
  49. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_account_info.py +0 -0
  50. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_basic.py +0 -0
  51. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_apps.py +0 -0
  52. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_connectors.py +0 -0
  53. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugin_bundles.py +0 -0
  54. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugin_shares.py +0 -0
  55. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugins.py +0 -0
  56. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_projects_gizmos.py +0 -0
  57. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_resources.py +0 -0
  58. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_search_files.py +0 -0
  59. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_writing_blocks.py +0 -0
  60. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_client_retry.py +0 -0
  61. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_codex_resources.py +0 -0
  62. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_codex_version.py +0 -0
  63. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_codex_web_search.py +0 -0
  64. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_conversation.py +0 -0
  65. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_files_resource.py +0 -0
  66. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_hosted_mcp.py +0 -0
  67. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_images.py +0 -0
  68. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_oauth.py +0 -0
  69. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_openai_compatibility.py +0 -0
  70. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_openai_oauth_resources.py +0 -0
  71. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_prompt_cache_usage.py +0 -0
  72. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_realtime_resource.py +0 -0
  73. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_reasoning.py +0 -0
  74. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_remote_control_resource.py +0 -0
  75. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_responses_resource.py +0 -0
  76. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_responses_routing.py +0 -0
  77. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_responses_websocket.py +0 -0
  78. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_structured_output.py +0 -0
  79. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_tools.py +0 -0
  80. {codex_backend_sdk-0.5.7 → codex_backend_sdk-0.5.8}/tests/test_worktree_snapshots.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.5.8] - 2026-10-22
6
+
7
+ ### Added
8
+ - Responses WebSocket `interrupt()` with active-response tracking,
9
+ acknowledgement validation, and draining before socket reuse.
10
+ - Source compatibility audit against Codex `0eb98b4617`, with native cloud
11
+ gRPC Resume/Attach explicitly inventoried as unsupported.
12
+
13
+ ### Fixed
14
+ - Preserve message IDs, status, phase, encrypted content, and backend metadata
15
+ when replaying Responses history; retire abandoned WebSocket streams.
16
+ - Serialize numeric custom reasoning effort strings as unsigned 64-bit JSON
17
+ integers and preserve reasoning context on attribute-based controls.
18
+ - Send and validate installed-plugin pagination limits with scoped requests.
19
+
5
20
  ## [0.5.7] - 2026-10-22
6
21
 
7
22
  ### 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.8
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.8"
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")
@@ -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
 
@@ -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;
@@ -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.8"
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()
@@ -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.8"
166
166
  source = { editable = "." }
167
167
  dependencies = [
168
168
  { name = "pydantic" },