codex-backend-sdk 0.5.6__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.6 → codex_backend_sdk-0.5.8}/CHANGELOG.md +24 -0
  2. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/PKG-INFO +37 -1
  3. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/README.md +36 -0
  4. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/__init__.py +1 -1
  5. codex_backend_sdk-0.5.8/codex_backend_sdk/_codex_version.py +45 -0
  6. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_models.py +2 -2
  7. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/_responses_payloads.py +26 -8
  8. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugins.py +2 -4
  9. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/models.py +7 -7
  10. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/responses_websocket.py +68 -7
  11. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/api-reference.md +18 -2
  12. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/backend-api.md +5 -1
  13. codex_backend_sdk-0.5.8/docs/codex-source-audit.md +61 -0
  14. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/desktop-endpoint-inventory.md +4 -0
  15. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/endpoint-coverage.md +5 -0
  16. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/pyproject.toml +1 -1
  17. codex_backend_sdk-0.5.8/tests/general/test_codex_source_contracts.py +227 -0
  18. codex_backend_sdk-0.5.8/tests/test_codex_version.py +88 -0
  19. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_responses_resource.py +28 -1
  20. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/uv.lock +1 -1
  21. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/.gitignore +0 -0
  22. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/LICENSE +0 -0
  23. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_client.py +0 -0
  24. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_streaming.py +0 -0
  25. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_transport.py +0 -0
  26. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_utils.py +0 -0
  27. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/codex_client.py +0 -0
  28. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/oauth.py +0 -0
  29. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/pkce.py +0 -0
  30. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/__init__.py +0 -0
  31. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/_responses_routing.py +0 -0
  32. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt.py +0 -0
  33. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_apps.py +0 -0
  34. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_connectors.py +0 -0
  35. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugin_bundles.py +0 -0
  36. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugin_shares.py +0 -0
  37. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_writing_blocks.py +0 -0
  38. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/codex.py +0 -0
  39. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/codex_web_search.py +0 -0
  40. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/files.py +0 -0
  41. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/images.py +0 -0
  42. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  43. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/realtime.py +0 -0
  44. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/remote_control.py +0 -0
  45. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/responses.py +0 -0
  46. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/worktree_snapshots.py +0 -0
  47. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/storage.py +0 -0
  48. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/openai-compatibility.md +0 -0
  49. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/examples/agent.py +0 -0
  50. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/conftest.py +0 -0
  51. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_account_info.py +0 -0
  52. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_basic.py +0 -0
  53. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_apps.py +0 -0
  54. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_connectors.py +0 -0
  55. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugin_bundles.py +0 -0
  56. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugin_shares.py +0 -0
  57. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugins.py +0 -0
  58. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_projects_gizmos.py +0 -0
  59. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_resources.py +0 -0
  60. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_search_files.py +0 -0
  61. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_writing_blocks.py +0 -0
  62. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_client_retry.py +0 -0
  63. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_codex_resources.py +0 -0
  64. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_codex_web_search.py +0 -0
  65. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_conversation.py +0 -0
  66. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_files_resource.py +0 -0
  67. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_hosted_mcp.py +0 -0
  68. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_images.py +0 -0
  69. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_oauth.py +0 -0
  70. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_openai_compatibility.py +0 -0
  71. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_openai_oauth_resources.py +0 -0
  72. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_prompt_cache_usage.py +0 -0
  73. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_realtime_resource.py +0 -0
  74. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_reasoning.py +0 -0
  75. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_remote_control_resource.py +0 -0
  76. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_responses_routing.py +0 -0
  77. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_responses_websocket.py +0 -0
  78. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_structured_output.py +0 -0
  79. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_tools.py +0 -0
  80. {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_worktree_snapshots.py +0 -0
@@ -2,6 +2,30 @@
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
+
20
+ ## [0.5.7] - 2026-10-22
21
+
22
+ ### Fixed
23
+ - Discover the latest stable Codex CLI version over credential-free HTTPS for
24
+ model catalogs instead of sending a stale fixed version that can hide models.
25
+ Share a 24-hour process cache, retain a verified fallback on registry failure,
26
+ and allow an explicit `extra_query` version without registry access.
27
+ - Keep catalogs fetched with custom request options out of the default cache.
28
+
5
29
  ## [0.5.6] - 2026-10-22
6
30
 
7
31
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codex-backend-sdk
3
- Version: 0.5.6
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
@@ -278,6 +296,24 @@ for model in client.models.list():
278
296
  model = client.models.retrieve("gpt-5.5")
279
297
  ```
280
298
 
299
+ Model discovery obtains the latest stable Codex CLI version over HTTPS from the
300
+ public npm registry; Node, npm and the Codex binary are not required. The version
301
+ is cached for 24 hours per process, shared across clients. The lookup uses a
302
+ separate request without backend credentials and a three-second timeout. On
303
+ failure it keeps the last known version (or a bundled verified fallback) and
304
+ retries no sooner than five minutes later. This only updates the advertised
305
+ catalog version, not the SDK's implementation of backend features.
306
+
307
+ To pin the catalog version and skip registry access entirely:
308
+
309
+ ```python
310
+ models = client.models.list(extra_query={"client_version": "0.159.2"})
311
+ ```
312
+
313
+ `force_refresh=True` bypasses the five-minute model catalog cache, not the
314
+ 24-hour version cache. Requests with explicit options do not replace the default
315
+ catalog cache. Other protocol and application version fields are independent.
316
+
281
317
  ### Upload a file
282
318
 
283
319
  ```python
@@ -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
@@ -249,6 +267,24 @@ for model in client.models.list():
249
267
  model = client.models.retrieve("gpt-5.5")
250
268
  ```
251
269
 
270
+ Model discovery obtains the latest stable Codex CLI version over HTTPS from the
271
+ public npm registry; Node, npm and the Codex binary are not required. The version
272
+ is cached for 24 hours per process, shared across clients. The lookup uses a
273
+ separate request without backend credentials and a three-second timeout. On
274
+ failure it keeps the last known version (or a bundled verified fallback) and
275
+ retries no sooner than five minutes later. This only updates the advertised
276
+ catalog version, not the SDK's implementation of backend features.
277
+
278
+ To pin the catalog version and skip registry access entirely:
279
+
280
+ ```python
281
+ models = client.models.list(extra_query={"client_version": "0.159.2"})
282
+ ```
283
+
284
+ `force_refresh=True` bypasses the five-minute model catalog cache, not the
285
+ 24-hour version cache. Requests with explicit options do not replace the default
286
+ catalog cache. Other protocol and application version fields are independent.
287
+
252
288
  ### Upload a file
253
289
 
254
290
  ```python
@@ -17,7 +17,7 @@ Quickstart:
17
17
  print(response.output_text)
18
18
  """
19
19
 
20
- __version__ = "0.5.6"
20
+ __version__ = "0.5.8"
21
21
 
22
22
  from .oauth import (
23
23
  DeviceCode,
@@ -0,0 +1,45 @@
1
+ """Shared discovery of the upstream Codex CLI version (not the SDK version)."""
2
+
3
+ import re
4
+ import threading
5
+ import time
6
+
7
+ import requests
8
+
9
+
10
+ # Last verified upstream protocol version, used when discovery is unavailable.
11
+ CLIENT_VERSION = "0.163.0"
12
+ _RETRY_TTL = 300
13
+ _VERSION_TTL = 24 * 60 * 60
14
+ _VERSION_URL = "https://registry.npmjs.org/@openai/codex/latest"
15
+ _version = CLIENT_VERSION
16
+ _version_refresh_at = 0.0
17
+ _version_lock = threading.Lock()
18
+
19
+
20
+ def codex_client_version() -> str:
21
+ """Discover the stable CLI version without sharing backend credentials.
22
+
23
+ Cache across clients in this process. Failed refreshes retain the last known
24
+ version and wait five minutes before retrying, including on first use.
25
+ """
26
+ global _version, _version_refresh_at
27
+ with _version_lock:
28
+ if time.monotonic() < _version_refresh_at:
29
+ return _version
30
+ ttl = _VERSION_TTL
31
+ try:
32
+ with requests.get(_VERSION_URL, timeout=3) as response:
33
+ response.raise_for_status()
34
+ payload = response.json()
35
+ candidate = payload.get("version") if isinstance(payload, dict) else None
36
+ if not isinstance(candidate, str) or not re.fullmatch(
37
+ r"[0-9]+\.[0-9]+\.[0-9]+", candidate
38
+ ):
39
+ raise ValueError("Invalid stable Codex version")
40
+ _version = candidate
41
+ except (requests.RequestException, ValueError):
42
+ ttl = _RETRY_TTL
43
+ _version_refresh_at = time.monotonic() + ttl
44
+ return _version
45
+
@@ -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")
@@ -5,18 +5,15 @@ from __future__ import annotations
5
5
  import time
6
6
  from typing import Any, TYPE_CHECKING
7
7
 
8
+ from .._codex_version import codex_client_version
8
9
  from .._models import Model, SyncPage
9
10
  from .._utils import _UNSET, CodexBackendUnsupportedParameterError, _is_given
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  from .._client import CodexClient
13
14
 
14
- # This is the upstream Codex CLI protocol/client version expected by the
15
- # ChatGPT backend, not the PyPI package version.
16
- CLIENT_VERSION = "0.130.0"
17
15
  _CACHE_TTL = 300
18
16
 
19
-
20
17
  class Models:
21
18
  def __init__(self, client: CodexClient) -> None:
22
19
  self._client = client
@@ -41,7 +38,9 @@ class Models:
41
38
  if time.time() - self._cache_fetched_at <= _CACHE_TTL:
42
39
  return self._cache
43
40
 
44
- params = {"client_version": CLIENT_VERSION, **(extra_query or {})}
41
+ params = dict(extra_query or {})
42
+ if "client_version" not in params:
43
+ params["client_version"] = codex_client_version()
45
44
  response = self._client._get_raw(
46
45
  "/models", params=params, headers=extra_headers, timeout=timeout
47
46
  )
@@ -49,8 +48,9 @@ class Models:
49
48
  models = [_model_from_backend(item) for item in data.get("models", [])]
50
49
  models.sort(key=lambda model: getattr(model, "priority", 0))
51
50
  page = SyncPage(data=models, etag=response.headers.get("ETag"))
52
- self._cache = page
53
- self._cache_fetched_at = time.time()
51
+ if not has_request_options:
52
+ self._cache = page
53
+ self._cache_fetched_at = time.time()
54
54
  return page
55
55
 
56
56
  def retrieve(
@@ -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,17 +172,26 @@ 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
 
176
192
  | Method | Parameters | Returns |
177
193
  | --- | --- | --- |
178
- | `client.models.list(...)` | `force_refresh=False`, `extra_headers=None`, `extra_query=None`, `extra_body=None`, `timeout=UNSET` | iterable `SyncPage` with optional `etag`; cached for 5 minutes |
194
+ | `client.models.list(...)` | `force_refresh=False`, `extra_headers=None`, `extra_query=None`, `extra_body=None`, `timeout=UNSET` | iterable `SyncPage` with optional `etag`; default catalog cached for 5 minutes; Codex version auto-discovered over HTTPS (24-hour process cache), or pinned via `extra_query["client_version"]` |
179
195
  | `client.models.retrieve(...)` | `model`, plus the same transport controls | one typed `Model`; raises `LookupError` if absent |
180
196
 
181
197
  `Model` preserves Codex fields including display metadata, context window,
@@ -78,7 +78,11 @@ List models available to this account.
78
78
  **SDK method**: `client.models.list()` / `client.models.retrieve(model)`
79
79
 
80
80
  **Query params**
81
- - `client_version` — Codex CLI protocol/client version string (e.g. `"0.130.0"`).
81
+ - `client_version` — Codex CLI version string. The SDK discovers the latest stable
82
+ version from the public npm registry over HTTPS (24-hour in-process cache;
83
+ three-second timeout; last-known/bundled fallback with five-minute retry
84
+ backoff). Override with `extra_query={"client_version": "0.159.2"}` to skip
85
+ discovery. No Node/npm installation is required.
82
86
 
83
87
  **Response** — JSON `{ "models": [ ModelObject, … ] }`
84
88
 
@@ -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.6"
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()
@@ -0,0 +1,88 @@
1
+ """Offline tests for upstream version discovery; no OAuth or npm binary."""
2
+
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from unittest.mock import MagicMock
5
+
6
+ import pytest
7
+ import requests
8
+
9
+ from codex_backend_sdk import _codex_version as version
10
+
11
+
12
+ @pytest.fixture
13
+ def registry(monkeypatch):
14
+ clock = [100.0]
15
+ monkeypatch.setattr(version.time, "monotonic", lambda: clock[0])
16
+ monkeypatch.setattr(version, "_version", version.CLIENT_VERSION)
17
+ monkeypatch.setattr(version, "_version_refresh_at", 0.0)
18
+ response = MagicMock()
19
+ response.__enter__.return_value = response
20
+ response.json.return_value = {"version": "0.170.0"}
21
+ get = MagicMock(return_value=response)
22
+ monkeypatch.setattr(version.requests, "get", get)
23
+ return clock, get, response
24
+
25
+
26
+ def test_version_uses_standalone_https_and_caches_for_24_hours(registry):
27
+ clock, get, response = registry
28
+ assert version.codex_client_version() == "0.170.0"
29
+ get.assert_called_once_with(version._VERSION_URL, timeout=3)
30
+ response.raise_for_status.assert_called_once()
31
+ response.__exit__.assert_called_once()
32
+ clock[0] += 86400 - 1
33
+ assert version.codex_client_version() == "0.170.0"
34
+ assert get.call_count == 1
35
+ clock[0] += 1
36
+ response.json.return_value = {"version": "0.171.0"}
37
+ assert version.codex_client_version() == "0.171.0"
38
+ assert get.call_count == 2
39
+
40
+
41
+ @pytest.mark.parametrize("error", [requests.Timeout(), requests.HTTPError(), ValueError()])
42
+ def test_first_failure_uses_verified_fallback_and_backs_off(registry, error):
43
+ clock, get, _ = registry
44
+ get.side_effect = error
45
+ assert version.codex_client_version() == version.CLIENT_VERSION
46
+ assert version.codex_client_version() == version.CLIENT_VERSION
47
+ assert get.call_count == 1
48
+ clock[0] += 300
49
+ get.side_effect = None
50
+ assert version.codex_client_version() == "0.170.0"
51
+ assert get.call_count == 2
52
+
53
+
54
+
55
+ @pytest.mark.parametrize("stage", ["raise_for_status", "json"])
56
+ def test_response_errors_close_request_and_use_fallback(registry, stage):
57
+ _, _, response = registry
58
+ error = requests.HTTPError() if stage == "raise_for_status" else ValueError()
59
+ getattr(response, stage).side_effect = error
60
+ assert version.codex_client_version() == version.CLIENT_VERSION
61
+ response.__exit__.assert_called_once()
62
+
63
+
64
+ def test_refresh_failure_keeps_last_success(registry):
65
+ clock, get, _ = registry
66
+ assert version.codex_client_version() == "0.170.0"
67
+ clock[0] += 86400
68
+ get.side_effect = requests.ConnectionError()
69
+ assert version.codex_client_version() == "0.170.0"
70
+ assert version.codex_client_version() == "0.170.0"
71
+ assert get.call_count == 2
72
+
73
+
74
+ @pytest.mark.parametrize("payload", [
75
+ None, [], {}, {"version": 123}, {"version": "0.170.0-beta.1"},
76
+ {"version": "garbage"}, {"version": "0.170.0\n"},
77
+ ])
78
+ def test_invalid_registry_metadata_uses_fallback(registry, payload):
79
+ _, _, response = registry
80
+ response.json.return_value = payload
81
+ assert version.codex_client_version() == version.CLIENT_VERSION
82
+
83
+
84
+ def test_concurrent_clients_share_one_lookup(registry):
85
+ _, get, _ = registry
86
+ with ThreadPoolExecutor(max_workers=8) as pool:
87
+ assert list(pool.map(lambda _: version.codex_client_version(), range(16))) == ["0.170.0"] * 16
88
+ assert get.call_count == 1
@@ -5,6 +5,12 @@ import pytest
5
5
  from pydantic import BaseModel
6
6
 
7
7
  from codex_backend_sdk import OpenAI, ParsedResponse, Response
8
+ from codex_backend_sdk.resources import models as models_resource
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def stable_catalog_version(monkeypatch):
13
+ monkeypatch.setattr(models_resource, "codex_client_version", lambda: "0.163.0")
8
14
 
9
15
 
10
16
  class FakeSSE:
@@ -363,12 +369,33 @@ def test_models_resource_forwards_official_request_options():
363
369
 
364
370
  assert client.gets[1] == (
365
371
  "/models",
366
- {"client_version": "0.130.0", "preview": "1"},
372
+ {"client_version": "0.163.0", "preview": "1"},
367
373
  {"x-test": "header"},
368
374
  19,
369
375
  )
370
376
 
371
377
 
378
+
379
+ def test_explicit_catalog_version_skips_discovery_and_does_not_replace_default_cache(monkeypatch):
380
+ client = FakeClient()
381
+ default = client.models.list()
382
+ def unexpected_lookup():
383
+ raise AssertionError("Explicit version must not contact registry")
384
+ monkeypatch.setattr(models_resource, "codex_client_version", unexpected_lookup)
385
+ custom = client.models.list(extra_query={"client_version": "0.159.2"})
386
+ assert custom is not default
387
+ assert client.gets[-1][1] == {"client_version": "0.159.2"}
388
+ assert client.models.list() is default
389
+
390
+
391
+ def test_forced_catalog_refresh_resolves_version_again(monkeypatch):
392
+ client = FakeClient()
393
+ client.models.list()
394
+ monkeypatch.setattr(models_resource, "codex_client_version", lambda: "0.171.0")
395
+ client.models.list(force_refresh=True)
396
+ assert client.gets[-1][1] == {"client_version": "0.171.0"}
397
+
398
+
372
399
  def test_responses_compact_sends_shared_request_fields():
373
400
  client = FakeClient()
374
401
 
@@ -162,7 +162,7 @@ wheels = [
162
162
 
163
163
  [[package]]
164
164
  name = "codex-backend-sdk"
165
- version = "0.5.6"
165
+ version = "0.5.8"
166
166
  source = { editable = "." }
167
167
  dependencies = [
168
168
  { name = "pydantic" },