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.
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/CHANGELOG.md +24 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/PKG-INFO +37 -1
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/README.md +36 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/__init__.py +1 -1
- codex_backend_sdk-0.5.8/codex_backend_sdk/_codex_version.py +45 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_models.py +2 -2
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/_responses_payloads.py +26 -8
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugins.py +2 -4
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/models.py +7 -7
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/responses_websocket.py +68 -7
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/api-reference.md +18 -2
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/backend-api.md +5 -1
- codex_backend_sdk-0.5.8/docs/codex-source-audit.md +61 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/desktop-endpoint-inventory.md +4 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/endpoint-coverage.md +5 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/pyproject.toml +1 -1
- codex_backend_sdk-0.5.8/tests/general/test_codex_source_contracts.py +227 -0
- codex_backend_sdk-0.5.8/tests/test_codex_version.py +88 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_responses_resource.py +28 -1
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/uv.lock +1 -1
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/.gitignore +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/LICENSE +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_client.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_streaming.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_transport.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/_utils.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/codex_client.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/oauth.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/pkce.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/__init__.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/_responses_routing.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_apps.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_connectors.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugin_bundles.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugin_shares.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_writing_blocks.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/codex.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/codex_web_search.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/files.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/images.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/openai_oauth.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/realtime.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/remote_control.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/responses.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/worktree_snapshots.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/storage.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/docs/openai-compatibility.md +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/examples/agent.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/conftest.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_account_info.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_basic.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_apps.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_connectors.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugin_bundles.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugin_shares.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_plugins.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_projects_gizmos.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_resources.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_search_files.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_chatgpt_writing_blocks.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_client_retry.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_codex_resources.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_codex_web_search.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_conversation.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_files_resource.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_hosted_mcp.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_images.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_oauth.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_openai_compatibility.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_openai_oauth_resources.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_prompt_cache_usage.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_realtime_resource.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_reasoning.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_remote_control_resource.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_responses_routing.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_responses_websocket.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_structured_output.py +0 -0
- {codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/tests/test_tools.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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]:
|
{codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_plugins.py
RENAMED
|
@@ -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 =
|
|
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
|
-
|
|
53
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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`,
|
|
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
|
|
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;
|
|
@@ -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.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/chatgpt_apps.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/codex_web_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/openai_oauth.py
RENAMED
|
File without changes
|
|
File without changes
|
{codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/remote_control.py
RENAMED
|
File without changes
|
{codex_backend_sdk-0.5.6 → codex_backend_sdk-0.5.8}/codex_backend_sdk/resources/responses.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|