codex-backend-sdk 0.3.0__tar.gz → 0.3.1__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 (35) hide show
  1. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/PKG-INFO +35 -4
  2. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/README.md +34 -3
  3. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/__init__.py +3 -1
  4. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/_client.py +21 -1
  5. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/_models.py +10 -0
  6. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/codex_client.py +2 -0
  7. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/resources/codex.py +25 -0
  8. codex_backend_sdk-0.3.1/codex_backend_sdk/resources/files.py +116 -0
  9. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/resources/models.py +17 -3
  10. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/resources/realtime.py +5 -11
  11. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/resources/responses.py +19 -2
  12. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/docs/backend-api.md +53 -3
  13. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/pyproject.toml +1 -1
  14. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_codex_resources.py +61 -0
  15. codex_backend_sdk-0.3.1/tests/test_files_resource.py +114 -0
  16. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_realtime_resource.py +6 -4
  17. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_responses_resource.py +73 -5
  18. codex_backend_sdk-0.3.0/tools/probe_v1_endpoints.py +0 -269
  19. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/.gitignore +0 -0
  20. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/LICENSE +0 -0
  21. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/_streaming.py +0 -0
  22. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/_utils.py +0 -0
  23. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/oauth.py +0 -0
  24. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/pkce.py +0 -0
  25. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/resources/__init__.py +0 -0
  26. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  27. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/codex_backend_sdk/storage.py +0 -0
  28. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/examples/agent.py +0 -0
  29. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/conftest.py +0 -0
  30. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_basic.py +0 -0
  31. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_conversation.py +0 -0
  32. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_openai_oauth_resources.py +0 -0
  33. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_reasoning.py +0 -0
  34. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_structured_output.py +0 -0
  35. {codex_backend_sdk-0.3.0 → codex_backend_sdk-0.3.1}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-backend-sdk
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Unofficial Python SDK for the ChatGPT Codex backend API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -184,17 +184,20 @@ resources (`responses`, `models`, `realtime`) or Codex-only resources (`codex`).
184
184
  |---|---|---|
185
185
  | `POST /backend-api/codex/responses` | `client.responses.create(...)` | Stream-only backend; non-streaming SDK calls are collected from SSE events. |
186
186
  | `POST /backend-api/codex/responses/compact` | `client.responses.compact(...)` | Codex-specific helper for encrypted context compaction. |
187
+ | `POST /backend-api/codex/memories/trace_summarize` | `client.codex.memories.trace_summarize(...)` | Raw Codex memory trace summarization helper. |
187
188
  | `GET /backend-api/codex/models` | `client.models.list()` / `client.models.retrieve(...)` | OpenAI-shaped model objects with Codex metadata preserved as extra fields. |
188
189
  | `POST /backend-api/codex/realtime/calls` | `client.realtime.calls.create(...)` | OpenAI-shaped SDP call creation for realtime sessions. |
189
190
  | `wss://api.openai.com/v1/realtime?model=...` | `client.realtime_websocket_url(...)` / `client.realtime_websocket_headers(...)` | Helper surface used by codex-agent's realtime plugin. |
190
191
  | `POST /v1/embeddings` | `client.embeddings.create(...)` | Uses the Codex OAuth access token against `api.openai.com`; verified with `text-embedding-3-small`. |
191
192
  | `POST /v1/audio/transcriptions` | `client.audio.transcriptions.create(...)` | Uses the Codex OAuth access token against `api.openai.com`; verified with `gpt-4o-mini-transcribe`. |
192
193
  | `GET /backend-api/wham/usage` | `client.codex.usage()` | Codex/ChatGPT quota and rate-limit status. |
194
+ | `GET /backend-api/wham/config/requirements` | `client.codex.config.requirements()` | Raw managed requirements/config payload for the authenticated account. |
193
195
  | `GET /backend-api/wham/tasks/list` | `client.codex.tasks.list(...)` | Raw Codex cloud task listing. |
194
196
  | `GET /backend-api/wham/tasks/{task_id}` | `client.codex.tasks.retrieve(task_id)` | Raw Codex cloud task detail. |
195
197
  | `GET /backend-api/wham/tasks/{task_id}/turns` | `client.codex.tasks.turns.list(task_id)` | Raw task turn mapping. |
196
198
  | `GET /backend-api/wham/tasks/{task_id}/turns/{turn_id}/sibling_turns` | `client.codex.tasks.turns.sibling_turns(task_id, turn_id)` | Raw sibling turn list. |
197
199
  | `GET /backend-api/wham/environments` | `client.codex.environments.list()` | Raw Codex cloud environment list. |
200
+ | `POST /backend-api/files` + signed upload | `client.files.upload(...)` | Uploads local files for Codex Apps/MCP file parameters and returns `sediment://...` metadata. |
198
201
  | `GET /backend-api/memories` | `client.codex.memories.list()` | Raw ChatGPT memory payload for the authenticated account. |
199
202
  | `GET /backend-api/user_system_messages` | `client.codex.user_system_messages.retrieve()` | Raw ChatGPT customization/system-message payload. |
200
203
 
@@ -403,18 +406,46 @@ not part of the official OpenAI SDK.
403
406
  ```python
404
407
  memories = client.codex.memories.list()
405
408
  customization = client.codex.user_system_messages.retrieve()
409
+ requirements = client.codex.config.requirements()
406
410
  ```
407
411
 
408
- Both methods return raw backend dictionaries because these payloads can contain
412
+ These methods return raw backend dictionaries because these payloads can contain
409
413
  personal account-specific fields and may change without notice.
410
414
 
415
+ `client.codex.memories.trace_summarize(...)` exposes the Codex memory
416
+ summarization endpoint used by the official client. It returns the raw backend
417
+ dictionary:
418
+
419
+ ```python
420
+ summary = client.codex.memories.trace_summarize(
421
+ model="gpt-5.4",
422
+ traces=[
423
+ {
424
+ "id": "trace_1",
425
+ "metadata": {"source_path": "memory.jsonl"},
426
+ "items": [{"type": "message", "content": "Remember this"}],
427
+ }
428
+ ],
429
+ reasoning={"effort": "low"},
430
+ )
431
+ ```
432
+
433
+ ### File Uploads
434
+
435
+ `client.files.upload(...)` follows the official Codex file flow for Apps/MCP
436
+ file parameters: create file metadata under ChatGPT, upload bytes to the signed
437
+ URL, then finalize the upload.
438
+
439
+ ```python
440
+ uploaded = client.files.upload("report.csv")
441
+ print(uploaded.uri) # sediment://file_...
442
+ ```
443
+
411
444
  ### Observed But Not Exposed
412
445
 
413
446
  The reverse-engineering notes in `docs/backend-api.md` include additional
414
447
  observed endpoints. They are not exposed as SDK resources yet because they are
415
448
  plan-gated, unavailable on `chatgpt.com`, or not stable enough:
416
449
 
417
- - `POST /backend-api/codex/memories/trace_summarize`
418
- - `GET /backend-api/wham/config/requirements`
419
450
  - `POST /v1/audio/speech` (auth reaches the endpoint, but Pro OAuth lacks
420
451
  `api.model.audio.request` in current tests)
@@ -158,17 +158,20 @@ resources (`responses`, `models`, `realtime`) or Codex-only resources (`codex`).
158
158
  |---|---|---|
159
159
  | `POST /backend-api/codex/responses` | `client.responses.create(...)` | Stream-only backend; non-streaming SDK calls are collected from SSE events. |
160
160
  | `POST /backend-api/codex/responses/compact` | `client.responses.compact(...)` | Codex-specific helper for encrypted context compaction. |
161
+ | `POST /backend-api/codex/memories/trace_summarize` | `client.codex.memories.trace_summarize(...)` | Raw Codex memory trace summarization helper. |
161
162
  | `GET /backend-api/codex/models` | `client.models.list()` / `client.models.retrieve(...)` | OpenAI-shaped model objects with Codex metadata preserved as extra fields. |
162
163
  | `POST /backend-api/codex/realtime/calls` | `client.realtime.calls.create(...)` | OpenAI-shaped SDP call creation for realtime sessions. |
163
164
  | `wss://api.openai.com/v1/realtime?model=...` | `client.realtime_websocket_url(...)` / `client.realtime_websocket_headers(...)` | Helper surface used by codex-agent's realtime plugin. |
164
165
  | `POST /v1/embeddings` | `client.embeddings.create(...)` | Uses the Codex OAuth access token against `api.openai.com`; verified with `text-embedding-3-small`. |
165
166
  | `POST /v1/audio/transcriptions` | `client.audio.transcriptions.create(...)` | Uses the Codex OAuth access token against `api.openai.com`; verified with `gpt-4o-mini-transcribe`. |
166
167
  | `GET /backend-api/wham/usage` | `client.codex.usage()` | Codex/ChatGPT quota and rate-limit status. |
168
+ | `GET /backend-api/wham/config/requirements` | `client.codex.config.requirements()` | Raw managed requirements/config payload for the authenticated account. |
167
169
  | `GET /backend-api/wham/tasks/list` | `client.codex.tasks.list(...)` | Raw Codex cloud task listing. |
168
170
  | `GET /backend-api/wham/tasks/{task_id}` | `client.codex.tasks.retrieve(task_id)` | Raw Codex cloud task detail. |
169
171
  | `GET /backend-api/wham/tasks/{task_id}/turns` | `client.codex.tasks.turns.list(task_id)` | Raw task turn mapping. |
170
172
  | `GET /backend-api/wham/tasks/{task_id}/turns/{turn_id}/sibling_turns` | `client.codex.tasks.turns.sibling_turns(task_id, turn_id)` | Raw sibling turn list. |
171
173
  | `GET /backend-api/wham/environments` | `client.codex.environments.list()` | Raw Codex cloud environment list. |
174
+ | `POST /backend-api/files` + signed upload | `client.files.upload(...)` | Uploads local files for Codex Apps/MCP file parameters and returns `sediment://...` metadata. |
172
175
  | `GET /backend-api/memories` | `client.codex.memories.list()` | Raw ChatGPT memory payload for the authenticated account. |
173
176
  | `GET /backend-api/user_system_messages` | `client.codex.user_system_messages.retrieve()` | Raw ChatGPT customization/system-message payload. |
174
177
 
@@ -377,18 +380,46 @@ not part of the official OpenAI SDK.
377
380
  ```python
378
381
  memories = client.codex.memories.list()
379
382
  customization = client.codex.user_system_messages.retrieve()
383
+ requirements = client.codex.config.requirements()
380
384
  ```
381
385
 
382
- Both methods return raw backend dictionaries because these payloads can contain
386
+ These methods return raw backend dictionaries because these payloads can contain
383
387
  personal account-specific fields and may change without notice.
384
388
 
389
+ `client.codex.memories.trace_summarize(...)` exposes the Codex memory
390
+ summarization endpoint used by the official client. It returns the raw backend
391
+ dictionary:
392
+
393
+ ```python
394
+ summary = client.codex.memories.trace_summarize(
395
+ model="gpt-5.4",
396
+ traces=[
397
+ {
398
+ "id": "trace_1",
399
+ "metadata": {"source_path": "memory.jsonl"},
400
+ "items": [{"type": "message", "content": "Remember this"}],
401
+ }
402
+ ],
403
+ reasoning={"effort": "low"},
404
+ )
405
+ ```
406
+
407
+ ### File Uploads
408
+
409
+ `client.files.upload(...)` follows the official Codex file flow for Apps/MCP
410
+ file parameters: create file metadata under ChatGPT, upload bytes to the signed
411
+ URL, then finalize the upload.
412
+
413
+ ```python
414
+ uploaded = client.files.upload("report.csv")
415
+ print(uploaded.uri) # sediment://file_...
416
+ ```
417
+
385
418
  ### Observed But Not Exposed
386
419
 
387
420
  The reverse-engineering notes in `docs/backend-api.md` include additional
388
421
  observed endpoints. They are not exposed as SDK resources yet because they are
389
422
  plan-gated, unavailable on `chatgpt.com`, or not stable enough:
390
423
 
391
- - `POST /backend-api/codex/memories/trace_summarize`
392
- - `GET /backend-api/wham/config/requirements`
393
424
  - `POST /v1/audio/speech` (auth reaches the endpoint, but Pro OAuth lacks
394
425
  `api.model.audio.request` in current tests)
@@ -17,7 +17,7 @@ Quickstart:
17
17
  print(response.output_text)
18
18
  """
19
19
 
20
- __version__ = "0.3.0"
20
+ __version__ = "0.3.1"
21
21
 
22
22
  from .oauth import run_oauth_flow, refresh_access_token, obtain_api_key
23
23
  from .storage import load_tokens, save_tokens, TokenStore
@@ -41,6 +41,7 @@ from .codex_client import (
41
41
  SyncPage,
42
42
  CompactedResponse,
43
43
  Transcription,
44
+ UploadedFile,
44
45
  image_url,
45
46
  image_b64,
46
47
  )
@@ -61,6 +62,7 @@ __all__ = [
61
62
  "SyncPage",
62
63
  "CompactedResponse",
63
64
  "Transcription",
65
+ "UploadedFile",
64
66
  "ReasoningEffort",
65
67
  "ReasoningSummary",
66
68
  "ServiceTier",
@@ -36,6 +36,7 @@ class CodexClient:
36
36
  from .resources.codex import CodexResources
37
37
  from .resources.models import Models
38
38
  from .resources.openai_oauth import Audio, Embeddings
39
+ from .resources.files import Files
39
40
  from .resources.realtime import Realtime
40
41
  from .resources.responses import Responses
41
42
 
@@ -53,6 +54,7 @@ class CodexClient:
53
54
  self.realtime = Realtime(self)
54
55
  self.embeddings = Embeddings(self)
55
56
  self.audio = Audio(self)
57
+ self.files = Files(self)
56
58
  self.codex = CodexResources(self)
57
59
 
58
60
  def authenticate(self, *, request_api_key: bool = True) -> "CodexClient":
@@ -140,6 +142,7 @@ class CodexClient:
140
142
  self,
141
143
  path: str,
142
144
  *,
145
+ body: Optional[dict[str, Any]] = None,
143
146
  content: Optional[bytes] = None,
144
147
  files: Any = None,
145
148
  data: Any = None,
@@ -150,7 +153,8 @@ class CodexClient:
150
153
  self._ensure_auth()
151
154
  response = self._session.post(
152
155
  f"{BASE_URL}{path}",
153
- data=content if files is None else data,
156
+ json=body if files is None and data is None and content is None else None,
157
+ data=content if content is not None else data,
154
158
  files=files,
155
159
  headers=headers,
156
160
  params=params,
@@ -221,6 +225,22 @@ class CodexClient:
221
225
  response.raise_for_status()
222
226
  return response.json()
223
227
 
228
+ def _post_chatgpt(
229
+ self,
230
+ path: str,
231
+ *,
232
+ body: dict[str, Any],
233
+ timeout: Any = _UNSET,
234
+ ) -> dict[str, Any]:
235
+ self._ensure_auth()
236
+ response = self._session.post(
237
+ f"{CHATGPT_BASE_URL}{path}",
238
+ json=body,
239
+ timeout=self._timeout if not _is_given(timeout) else timeout,
240
+ )
241
+ response.raise_for_status()
242
+ return response.json()
243
+
224
244
  def realtime_websocket_url(self, *, model: str) -> str:
225
245
  """Return the official OpenAI Realtime WebSocket URL for Codex plugins."""
226
246
  if not model:
@@ -173,6 +173,16 @@ class Transcription(CodexBaseModel):
173
173
  text: str = ""
174
174
 
175
175
 
176
+ class UploadedFile(CodexBaseModel):
177
+ file_id: str
178
+ uri: str
179
+ download_url: str
180
+ file_name: str
181
+ file_size_bytes: int
182
+ mime_type: Optional[str] = None
183
+ path: str
184
+
185
+
176
186
  class CompactedResponse(CodexBaseModel):
177
187
  id: str
178
188
  object: str = "response.compacted"
@@ -24,6 +24,7 @@ from ._models import (
24
24
  SyncPage,
25
25
  TokenDetails,
26
26
  Transcription,
27
+ UploadedFile,
27
28
  Verbosity,
28
29
  )
29
30
  from ._utils import CodexBackendUnsupportedParameterError, image_b64, image_url
@@ -48,6 +49,7 @@ __all__ = [
48
49
  "SyncPage",
49
50
  "TokenDetails",
50
51
  "Transcription",
52
+ "UploadedFile",
51
53
  "Verbosity",
52
54
  "image_b64",
53
55
  "image_url",
@@ -16,6 +16,7 @@ class CodexResources:
16
16
  self.memories = CodexMemories(client)
17
17
  self.tasks = CodexTasks(client)
18
18
  self.environments = CodexEnvironments(client)
19
+ self.config = CodexConfig(client)
19
20
  self.user_system_messages = CodexUserSystemMessages(client)
20
21
 
21
22
  def usage(self) -> dict[str, Any]:
@@ -31,6 +32,30 @@ class CodexMemories:
31
32
  def list(self) -> dict[str, Any]:
32
33
  return self._client._get_chatgpt("/memories")
33
34
 
35
+ def trace_summarize(
36
+ self,
37
+ *,
38
+ model: str,
39
+ traces: list[dict[str, Any]],
40
+ reasoning: dict[str, Any] | None = None,
41
+ ) -> dict[str, Any]:
42
+ if not model:
43
+ raise ValueError(f"Expected a non-empty value for `model` but received {model!r}")
44
+ payload: dict[str, Any] = {"model": model, "traces": traces}
45
+ if reasoning is not None:
46
+ payload["reasoning"] = reasoning
47
+ return self._client._post("/memories/trace_summarize", body=payload).json()
48
+
49
+
50
+ class CodexConfig:
51
+ """Codex WHAM account configuration resources."""
52
+
53
+ def __init__(self, client: CodexClient) -> None:
54
+ self._client = client
55
+
56
+ def requirements(self) -> dict[str, Any]:
57
+ return self._client._get_wham("/wham/config/requirements")
58
+
34
59
 
35
60
  class CodexTasks:
36
61
  """Codex cloud task data for the authenticated account."""
@@ -0,0 +1,116 @@
1
+ """ChatGPT file upload helpers used by Codex Apps tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, TYPE_CHECKING
9
+
10
+ import requests
11
+
12
+ from .._models import UploadedFile
13
+ from .._utils import _UNSET, _is_given
14
+
15
+ if TYPE_CHECKING:
16
+ from .._client import CodexClient
17
+
18
+ FILE_URI_PREFIX = "sediment://"
19
+ FILE_UPLOAD_LIMIT_BYTES = 512 * 1024 * 1024
20
+ FILE_USE_CASE = "codex"
21
+
22
+
23
+ class Files:
24
+ """File uploads for Codex Apps/MCP file parameters."""
25
+
26
+ def __init__(self, client: CodexClient) -> None:
27
+ self._client = client
28
+
29
+ def upload(
30
+ self,
31
+ path: str | Path,
32
+ *,
33
+ timeout: Any = _UNSET,
34
+ finalize_timeout: float = 30,
35
+ finalize_retry_delay: float = 0.25,
36
+ ) -> UploadedFile:
37
+ file_path = Path(path)
38
+ if not file_path.exists():
39
+ raise FileNotFoundError(f"path `{file_path}` does not exist")
40
+ if not file_path.is_file():
41
+ raise ValueError(f"path `{file_path}` is not a file")
42
+
43
+ size = file_path.stat().st_size
44
+ if size > FILE_UPLOAD_LIMIT_BYTES:
45
+ raise ValueError(
46
+ f"file `{file_path}` is too large: {size} bytes exceeds "
47
+ f"the limit of {FILE_UPLOAD_LIMIT_BYTES} bytes"
48
+ )
49
+
50
+ file_name = file_path.name or "file"
51
+ create_payload = self._client._post_chatgpt(
52
+ "/files",
53
+ body={"file_name": file_name, "file_size": size, "use_case": FILE_USE_CASE},
54
+ timeout=timeout,
55
+ )
56
+ file_id = create_payload["file_id"]
57
+ upload_url = create_payload["upload_url"]
58
+
59
+ with file_path.open("rb") as handle:
60
+ upload_response = requests.put(
61
+ upload_url,
62
+ data=handle,
63
+ headers={
64
+ "x-ms-blob-type": "BlockBlob",
65
+ "Content-Length": str(size),
66
+ },
67
+ timeout=self._request_timeout(timeout),
68
+ )
69
+ upload_response.raise_for_status()
70
+
71
+ finalize_payload = self._finalize_upload(
72
+ file_id,
73
+ timeout=timeout,
74
+ finalize_timeout=finalize_timeout,
75
+ finalize_retry_delay=finalize_retry_delay,
76
+ )
77
+ return UploadedFile(
78
+ file_id=file_id,
79
+ uri=f"{FILE_URI_PREFIX}{file_id}",
80
+ download_url=finalize_payload["download_url"],
81
+ file_name=finalize_payload.get("file_name") or file_name,
82
+ file_size_bytes=size,
83
+ mime_type=finalize_payload.get("mime_type") or mimetypes.guess_type(file_name)[0],
84
+ path=str(file_path),
85
+ )
86
+
87
+ def _finalize_upload(
88
+ self,
89
+ file_id: str,
90
+ *,
91
+ timeout: Any,
92
+ finalize_timeout: float,
93
+ finalize_retry_delay: float,
94
+ ) -> dict[str, Any]:
95
+ deadline = time.monotonic() + finalize_timeout
96
+ while True:
97
+ payload = self._client._post_chatgpt(
98
+ f"/files/{file_id}/uploaded",
99
+ body={},
100
+ timeout=timeout,
101
+ )
102
+ status = payload.get("status")
103
+ if status == "success":
104
+ if not payload.get("download_url"):
105
+ raise RuntimeError(f"OpenAI file upload for `{file_id}` failed: missing download_url")
106
+ return payload
107
+ if status == "retry" and time.monotonic() < deadline:
108
+ time.sleep(finalize_retry_delay)
109
+ continue
110
+ if status == "retry":
111
+ raise TimeoutError(f"OpenAI file upload for `{file_id}` is not ready yet")
112
+ message = payload.get("error_message") or "upload finalization returned an error"
113
+ raise RuntimeError(f"OpenAI file upload for `{file_id}` failed: {message}")
114
+
115
+ def _request_timeout(self, timeout: Any) -> Any:
116
+ return self._client._timeout if not _is_given(timeout) else timeout
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import time
5
6
  from typing import Any, TYPE_CHECKING
6
7
 
7
8
  from .._models import Model, SyncPage
@@ -10,25 +11,38 @@ from .._utils import _UNSET
10
11
  if TYPE_CHECKING:
11
12
  from .._client import CodexClient
12
13
 
13
- CLIENT_VERSION = "0.3.0"
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
+ _CACHE_TTL = 300
14
18
 
15
19
 
16
20
  class Models:
17
21
  def __init__(self, client: CodexClient) -> None:
18
22
  self._client = client
23
+ self._cache: SyncPage | None = None
24
+ self._cache_fetched_at = 0.0
19
25
 
20
26
  def list(
21
27
  self,
22
28
  *,
29
+ force_refresh: bool = False,
23
30
  extra_headers: Any = None,
24
31
  extra_query: Any = None,
25
32
  extra_body: Any = None,
26
33
  timeout: Any = _UNSET,
27
34
  ) -> SyncPage:
35
+ if not force_refresh and self._cache is not None:
36
+ if time.time() - self._cache_fetched_at <= _CACHE_TTL:
37
+ return self._cache
38
+
28
39
  data = self._client._get("/models", params={"client_version": CLIENT_VERSION})
29
40
  models = [_model_from_backend(item) for item in data.get("models", [])]
30
- models.sort(key=lambda model: getattr(model, "priority", 0), reverse=True)
31
- return SyncPage(data=models)
41
+ models.sort(key=lambda model: getattr(model, "priority", 0))
42
+ page = SyncPage(data=models)
43
+ self._cache = page
44
+ self._cache_fetched_at = time.time()
45
+ return page
32
46
 
33
47
  def retrieve(
34
48
  self,
@@ -1,8 +1,6 @@
1
1
  """Realtime resources."""
2
2
 
3
3
  from __future__ import annotations
4
-
5
- import json
6
4
  from typing import Any, Optional, TYPE_CHECKING
7
5
 
8
6
  from .._models import RealtimeCallResponse
@@ -51,17 +49,13 @@ class RealtimeCalls:
51
49
  )
52
50
  return RealtimeCallResponse(response)
53
51
 
54
- files = [
55
- ("sdp", (None, sdp.encode("utf-8"), "application/sdp")),
56
- (
57
- "session",
58
- (None, json.dumps(_jsonable(session)).encode("utf-8"), "application/json"),
59
- ),
60
- ]
61
52
  response = self._client._post_raw(
62
53
  "/realtime/calls",
63
- files=files,
64
- data=extra_body,
54
+ body={
55
+ "sdp": sdp,
56
+ "session": _jsonable(session),
57
+ **(_jsonable(extra_body) if extra_body else {}),
58
+ },
65
59
  headers={"Accept": "application/sdp", **(extra_headers or {})},
66
60
  params=extra_query,
67
61
  timeout=timeout,
@@ -115,14 +115,31 @@ class Responses:
115
115
  input: list[dict[str, Any]],
116
116
  model: Any = _UNSET,
117
117
  instructions: Any = _UNSET,
118
+ tools: Any = _UNSET,
119
+ parallel_tool_calls: Any = _UNSET,
120
+ reasoning: Any = _UNSET,
121
+ service_tier: Any = _UNSET,
122
+ prompt_cache_key: Any = _UNSET,
123
+ text: Any = _UNSET,
118
124
  ) -> CompactedResponse:
125
+ normalized_tools = _normalize_tools(tools)
119
126
  payload = {
120
127
  "model": _default(model, self._client._defaults["model"]),
121
128
  "instructions": _default(instructions, self._client._defaults["instructions"]) or "",
122
129
  "input": [_normalize_input_item(item) for item in input],
123
- "tools": [],
124
- "parallel_tool_calls": False,
130
+ "tools": normalized_tools,
131
+ "parallel_tool_calls": (
132
+ bool(_default(parallel_tool_calls, False)) if normalized_tools else False
133
+ ),
125
134
  }
135
+ if _is_given(reasoning) and reasoning is not None:
136
+ payload["reasoning"] = _normalize_reasoning(reasoning)
137
+ if _is_given(service_tier) and service_tier is not None:
138
+ payload["service_tier"] = service_tier
139
+ if _is_given(prompt_cache_key) and prompt_cache_key is not None:
140
+ payload["prompt_cache_key"] = prompt_cache_key
141
+ if _is_given(text) and text is not None:
142
+ payload["text"] = _normalize_text(text)
126
143
  data = self._client._post("/responses/compact", body=payload).json()
127
144
  return CompactedResponse(id=data.get("id", ""), output=data.get("output", []))
128
145
 
@@ -43,7 +43,7 @@ List models available to this account.
43
43
  **SDK method**: `client.models.list()` / `client.models.retrieve(model)`
44
44
 
45
45
  **Query params**
46
- - `client_version` — SDK/CLI version string (e.g. `"0.3.0"`).
46
+ - `client_version` — Codex CLI protocol/client version string (e.g. `"0.130.0"`).
47
47
 
48
48
  **Response** — JSON `{ "models": [ ModelObject, … ] }`
49
49
 
@@ -193,7 +193,14 @@ Compact a long conversation into an encrypted summary the model can still read.
193
193
  ```json
194
194
  {
195
195
  "model": "gpt-5.4",
196
- "input": [ /* full conversation history */ ]
196
+ "input": [ /* full conversation history */ ],
197
+ "instructions": "Compact the conversation.",
198
+ "tools": [],
199
+ "parallel_tool_calls": false,
200
+ "reasoning": { "effort": "medium" },
201
+ "service_tier": "priority",
202
+ "prompt_cache_key": "cache-key",
203
+ "text": { "verbosity": "low" }
197
204
  }
198
205
  ```
199
206
 
@@ -219,7 +226,27 @@ Compact a long conversation into an encrypted summary the model can still read.
219
226
 
220
227
  Summarize traces into persistent memories.
221
228
 
222
- **Status**: Returns `403 Forbidden` on Plus plan. Requires Pro or Enterprise.
229
+ **SDK method**: `client.codex.memories.trace_summarize(...)`
230
+
231
+ **Status**: Supported as a raw helper. May return `403 Forbidden` depending on
232
+ plan/account capabilities.
233
+
234
+ **Request body**:
235
+ ```json
236
+ {
237
+ "model": "gpt-5.4",
238
+ "traces": [
239
+ {
240
+ "id": "trace_1",
241
+ "metadata": { "source_path": "memory.jsonl" },
242
+ "items": [ /* normalized trace items */ ]
243
+ }
244
+ ],
245
+ "reasoning": { "effort": "low" }
246
+ }
247
+ ```
248
+
249
+ **Response** — raw JSON, typically `{ "output": [...] }`.
223
250
 
224
251
  ---
225
252
 
@@ -342,6 +369,8 @@ Known `rate_limit_reached_type.type` values: `rate_limit_reached`, `workspace_ow
342
369
 
343
370
  Fetch managed requirements/config for this account (plan-gated settings).
344
371
 
372
+ **SDK method**: `client.codex.config.requirements()`
373
+
345
374
  **Response** — JSON config blob; schema defined in `codex-rs/cloud-requirements`.
346
375
 
347
376
  ---
@@ -404,6 +433,27 @@ Remote control / agent-as-a-service websocket. Enrollment via:
404
433
 
405
434
  ---
406
435
 
436
+ ## ChatGPT File Uploads
437
+
438
+ ### `POST /backend-api/files`
439
+
440
+ Create file upload metadata for Codex Apps/MCP file parameters.
441
+
442
+ **SDK method**: `client.files.upload(path)`
443
+
444
+ The official flow is:
445
+
446
+ 1. `POST /backend-api/files` with `file_name`, `file_size`, and
447
+ `use_case: "codex"`.
448
+ 2. `PUT` the file bytes to the returned signed `upload_url`.
449
+ 3. `POST /backend-api/files/{file_id}/uploaded` until the backend returns
450
+ `status: "success"`.
451
+
452
+ The SDK returns an `UploadedFile` object with `file_id`, canonical
453
+ `sediment://...` URI, download URL, file name, size, MIME type, and local path.
454
+
455
+ ---
456
+
407
457
  ## ChatGPT Account Data
408
458
 
409
459
  These endpoints are under `https://chatgpt.com/backend-api`, not
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-backend-sdk"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Unofficial Python SDK for the ChatGPT Codex backend API"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -6,11 +6,14 @@ class FakeCodexClient(OpenAI):
6
6
  super().__init__(model="gpt-test")
7
7
  self.wham_gets = []
8
8
  self.chatgpt_gets = []
9
+ self.posts = []
9
10
 
10
11
  def _get_wham(self, path, *, params=None):
11
12
  self.wham_gets.append((path, params))
12
13
  if path == "/wham/usage":
13
14
  return {"rate_limit": {"allowed": True}}
15
+ if path == "/wham/config/requirements":
16
+ return {"requirements": [{"name": "network"}]}
14
17
  if path == "/wham/tasks/list":
15
18
  return {"items": [{"id": "task_1", "title": "Task"}], "cursor": None}
16
19
  if path == "/wham/tasks/task_1":
@@ -35,6 +38,20 @@ class FakeCodexClient(OpenAI):
35
38
  return {"object": "user_system_message_detail", "enabled": True}
36
39
  raise AssertionError(f"Unexpected path: {path}")
37
40
 
41
+ def _post(self, path, *, body, stream=False):
42
+ self.posts.append((path, body, stream))
43
+ if path == "/memories/trace_summarize":
44
+ return FakeJSONResponse({"output": [{"memory_summary": "summary"}]})
45
+ raise AssertionError(f"Unexpected Codex post path: {path}")
46
+
47
+
48
+ class FakeJSONResponse:
49
+ def __init__(self, payload):
50
+ self._payload = payload
51
+
52
+ def json(self):
53
+ return self._payload
54
+
38
55
 
39
56
  def test_codex_usage_still_calls_wham_usage():
40
57
  client = FakeCodexClient()
@@ -54,6 +71,41 @@ def test_codex_memories_list_returns_raw_chatgpt_payload():
54
71
  assert client.chatgpt_gets == ["/memories"]
55
72
 
56
73
 
74
+ def test_codex_memories_trace_summarize_posts_codex_payload():
75
+ client = FakeCodexClient()
76
+
77
+ response = client.codex.memories.trace_summarize(
78
+ model="gpt-test",
79
+ traces=[
80
+ {
81
+ "id": "trace_1",
82
+ "metadata": {"source_path": "/tmp/memory.jsonl"},
83
+ "items": [{"type": "message", "content": "remember me"}],
84
+ }
85
+ ],
86
+ reasoning={"effort": "low"},
87
+ )
88
+
89
+ assert response == {"output": [{"memory_summary": "summary"}]}
90
+ assert client.posts == [
91
+ (
92
+ "/memories/trace_summarize",
93
+ {
94
+ "model": "gpt-test",
95
+ "traces": [
96
+ {
97
+ "id": "trace_1",
98
+ "metadata": {"source_path": "/tmp/memory.jsonl"},
99
+ "items": [{"type": "message", "content": "remember me"}],
100
+ }
101
+ ],
102
+ "reasoning": {"effort": "low"},
103
+ },
104
+ False,
105
+ )
106
+ ]
107
+
108
+
57
109
  def test_codex_user_system_messages_retrieve_returns_raw_chatgpt_payload():
58
110
  client = FakeCodexClient()
59
111
 
@@ -110,3 +162,12 @@ def test_codex_environments_list_returns_raw_payload():
110
162
 
111
163
  assert environments == [{"id": "env_1", "label": "default"}]
112
164
  assert client.wham_gets == [("/wham/environments", None)]
165
+
166
+
167
+ def test_codex_config_requirements_returns_raw_payload():
168
+ client = FakeCodexClient()
169
+
170
+ requirements = client.codex.config.requirements()
171
+
172
+ assert requirements == {"requirements": [{"name": "network"}]}
173
+ assert client.wham_gets == [("/wham/config/requirements", None)]
@@ -0,0 +1,114 @@
1
+ from pathlib import Path
2
+
3
+ from codex_backend_sdk import OpenAI, UploadedFile
4
+ from codex_backend_sdk.storage import TokenStore
5
+
6
+
7
+ class FakePutResponse:
8
+ def __init__(self):
9
+ self.raised = False
10
+
11
+ def raise_for_status(self):
12
+ self.raised = True
13
+
14
+
15
+ class FakeFilesClient(OpenAI):
16
+ def __init__(self):
17
+ super().__init__(model="gpt-test")
18
+ self.chatgpt_posts = []
19
+ self._set_store(TokenStore(
20
+ access_token="chatgpt-token",
21
+ refresh_token="refresh-token",
22
+ id_token_raw="id-token",
23
+ account_id="account-id",
24
+ ))
25
+
26
+ def _post_chatgpt(self, path, *, body, timeout=None):
27
+ self.chatgpt_posts.append((path, body, timeout))
28
+ if path == "/files":
29
+ return {"file_id": "file_123", "upload_url": "https://upload.example/file_123"}
30
+ if path == "/files/file_123/uploaded":
31
+ return {
32
+ "status": "success",
33
+ "download_url": "https://download.example/file_123",
34
+ "file_name": "report.txt",
35
+ "mime_type": "text/plain",
36
+ }
37
+ raise AssertionError(f"Unexpected ChatGPT post path: {path}")
38
+
39
+
40
+ def test_files_upload_uses_chatgpt_file_flow(tmp_path, monkeypatch):
41
+ client = FakeFilesClient()
42
+ file_path = tmp_path / "report.txt"
43
+ file_path.write_text("hello")
44
+ put_calls = []
45
+
46
+ def fake_put(url, *, data, headers, timeout):
47
+ put_calls.append((url, data.read(), headers, timeout))
48
+ return FakePutResponse()
49
+
50
+ monkeypatch.setattr("codex_backend_sdk.resources.files.requests.put", fake_put)
51
+
52
+ uploaded = client.files.upload(file_path)
53
+
54
+ assert isinstance(uploaded, UploadedFile)
55
+ assert uploaded.file_id == "file_123"
56
+ assert uploaded.uri == "sediment://file_123"
57
+ assert uploaded.download_url == "https://download.example/file_123"
58
+ assert uploaded.file_name == "report.txt"
59
+ assert uploaded.file_size_bytes == 5
60
+ assert uploaded.mime_type == "text/plain"
61
+ assert uploaded.path == str(file_path)
62
+ assert client.chatgpt_posts[0][:2] == (
63
+ "/files",
64
+ {"file_name": "report.txt", "file_size": 5, "use_case": "codex"},
65
+ )
66
+ assert client.chatgpt_posts[1][:2] == ("/files/file_123/uploaded", {})
67
+ assert put_calls == [
68
+ (
69
+ "https://upload.example/file_123",
70
+ b"hello",
71
+ {"x-ms-blob-type": "BlockBlob", "Content-Length": "5"},
72
+ 120,
73
+ )
74
+ ]
75
+
76
+
77
+ def test_files_upload_rejects_missing_path():
78
+ client = FakeFilesClient()
79
+
80
+ try:
81
+ client.files.upload(Path("/does/not/exist"))
82
+ except FileNotFoundError as exc:
83
+ assert "does not exist" in str(exc)
84
+ else:
85
+ raise AssertionError("missing path should fail before network calls")
86
+
87
+
88
+ def test_files_upload_retries_finalize_payload(tmp_path, monkeypatch):
89
+ class RetryFilesClient(FakeFilesClient):
90
+ def __init__(self):
91
+ super().__init__()
92
+ self.finalize_calls = 0
93
+
94
+ def _post_chatgpt(self, path, *, body, timeout=None):
95
+ if path == "/files/file_123/uploaded":
96
+ self.finalize_calls += 1
97
+ if self.finalize_calls == 1:
98
+ return {"status": "retry"}
99
+ return super()._post_chatgpt(path, body=body, timeout=timeout)
100
+
101
+ client = RetryFilesClient()
102
+ file_path = tmp_path / "report.txt"
103
+ file_path.write_text("hello")
104
+
105
+ monkeypatch.setattr(
106
+ "codex_backend_sdk.resources.files.requests.put",
107
+ lambda *args, **kwargs: FakePutResponse(),
108
+ )
109
+ monkeypatch.setattr("codex_backend_sdk.resources.files.time.sleep", lambda delay: None)
110
+
111
+ uploaded = client.files.upload(file_path, finalize_retry_delay=0)
112
+
113
+ assert uploaded.file_id == "file_123"
114
+ assert client.finalize_calls == 2
@@ -51,7 +51,7 @@ def test_realtime_calls_create_posts_plain_sdp_like_official_sdk():
51
51
  assert kwargs["headers"]["Content-Type"] == "application/sdp"
52
52
 
53
53
 
54
- def test_realtime_calls_create_posts_session_as_multipart():
54
+ def test_realtime_calls_create_posts_session_as_backend_json():
55
55
  client = FakeRealtimeClient()
56
56
 
57
57
  client.realtime.calls.create(
@@ -61,9 +61,11 @@ def test_realtime_calls_create_posts_session_as_multipart():
61
61
 
62
62
  path, kwargs = client.raw_posts[0]
63
63
  assert path == "/realtime/calls"
64
- assert kwargs["files"][0][0] == "sdp"
65
- assert kwargs["files"][1][0] == "session"
66
- assert b"gpt-realtime-1.5" in kwargs["files"][1][1][1]
64
+ assert kwargs["body"] == {
65
+ "sdp": "offer-sdp",
66
+ "session": {"type": "realtime", "model": "gpt-realtime-1.5"},
67
+ }
68
+ assert kwargs["headers"]["Accept"] == "application/sdp"
67
69
 
68
70
 
69
71
  def test_realtime_websocket_helpers_match_codex_agent_plugin_contract():
@@ -14,6 +14,14 @@ class FakeSSE:
14
14
  yield b""
15
15
 
16
16
 
17
+ class FakeJSONResponse:
18
+ def __init__(self, payload):
19
+ self._payload = payload
20
+
21
+ def json(self):
22
+ return self._payload
23
+
24
+
17
25
  class FakeClient(OpenAI):
18
26
  def __init__(self):
19
27
  super().__init__(model="gpt-test")
@@ -22,6 +30,11 @@ class FakeClient(OpenAI):
22
30
 
23
31
  def _post(self, path, *, body, stream=False):
24
32
  self.posts.append((path, body, stream))
33
+ if path == "/responses/compact":
34
+ return FakeJSONResponse({
35
+ "id": "resp_123",
36
+ "output": [{"type": "message", "content": []}],
37
+ })
25
38
  return FakeSSE([
26
39
  'data: {"type":"response.content_part.delta","delta":{"text":"hel"}}',
27
40
  "",
@@ -39,12 +52,20 @@ class FakeClient(OpenAI):
39
52
  return {
40
53
  "models": [
41
54
  {
42
- "slug": "gpt-test",
43
- "display_name": "GPT Test",
55
+ "slug": "gpt-lower-priority",
56
+ "display_name": "GPT Lower Priority",
44
57
  "description": "Test model",
45
58
  "context_window": 123,
46
59
  "supported_in_api": True,
47
60
  "priority": 7,
61
+ },
62
+ {
63
+ "slug": "gpt-test",
64
+ "display_name": "GPT Test",
65
+ "description": "Test model",
66
+ "context_window": 456,
67
+ "supported_in_api": True,
68
+ "priority": 1,
48
69
  }
49
70
  ]
50
71
  }
@@ -123,11 +144,58 @@ def test_models_resource_returns_iterable_page():
123
144
  page = client.models.list()
124
145
  model = page[0]
125
146
 
126
- assert len(page) == 1
127
- assert list(page) == [model]
147
+ assert len(page) == 2
128
148
  assert model.id == "gpt-test"
129
- assert model.context_window == 123
149
+ assert model.context_window == 456
130
150
  assert client.models.retrieve("gpt-test").id == "gpt-test"
151
+ assert len(client.gets) == 1
152
+
153
+
154
+ def test_models_resource_can_force_refresh_cached_page():
155
+ client = FakeClient()
156
+
157
+ assert client.models.list() is client.models.list()
158
+ client.models.list(force_refresh=True)
159
+
160
+ assert len(client.gets) == 2
161
+
162
+
163
+ def test_responses_compact_sends_shared_request_fields():
164
+ client = FakeClient()
165
+
166
+ compacted = client.responses.compact(
167
+ model="gpt-test",
168
+ input=[{"role": "user", "content": "Long context"}],
169
+ instructions="Compact this.",
170
+ tools=[{"type": "web_search"}],
171
+ parallel_tool_calls=True,
172
+ reasoning={"effort": "medium"},
173
+ service_tier="priority",
174
+ prompt_cache_key="cache-key",
175
+ text={"verbosity": "low"},
176
+ )
177
+
178
+ assert compacted.id == "resp_123"
179
+ path, payload, stream = client.posts[0]
180
+ assert path == "/responses/compact"
181
+ assert stream is False
182
+ assert payload == {
183
+ "model": "gpt-test",
184
+ "instructions": "Compact this.",
185
+ "input": [
186
+ {
187
+ "type": "message",
188
+ "role": "user",
189
+ "content": [{"type": "input_text", "text": "Long context"}],
190
+ }
191
+ ],
192
+ "tools": [{"type": "web_search_preview"}],
193
+ "parallel_tool_calls": True,
194
+ "reasoning": {"effort": "medium"},
195
+ "service_tier": "priority",
196
+ "prompt_cache_key": "cache-key",
197
+ "text": {"verbosity": "low"},
198
+ }
131
199
 
132
200
 
133
201
  def test_responses_create_rejects_official_params_not_exposed_by_codex_backend():
@@ -1,269 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Probe OpenAI/Codex endpoints available to the current ChatGPT auth store.
3
-
4
- The default mode is intentionally read-only: it sends GET/OPTIONS requests to
5
- candidate endpoints and records status codes, content types, request IDs, and a
6
- small body preview. It does not create resources.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import json
13
- import time
14
- from dataclasses import asdict, dataclass
15
- from pathlib import Path
16
- from typing import Any
17
- from urllib.parse import urljoin
18
-
19
- import requests
20
-
21
- from codex_backend_sdk.storage import TokenStore, load_tokens
22
-
23
-
24
- OPENAI_BASE_URL = "https://api.openai.com/v1"
25
- CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
26
- WHAM_BASE_URL = "https://chatgpt.com/backend-api"
27
- ORIGINATOR = "codex_cli_rs"
28
-
29
-
30
- @dataclass(frozen=True)
31
- class Candidate:
32
- name: str
33
- base: str
34
- method: str
35
- path: str
36
- auth: str
37
- params: dict[str, Any] | None = None
38
- headers: dict[str, str] | None = None
39
- body: dict[str, Any] | None = None
40
- mutating: bool = False
41
- note: str = ""
42
-
43
-
44
- @dataclass
45
- class ProbeResult:
46
- name: str
47
- base: str
48
- method: str
49
- path: str
50
- url: str
51
- auth: str
52
- status_code: int | None
53
- category: str
54
- elapsed_ms: int
55
- content_type: str | None = None
56
- request_id: str | None = None
57
- allow: str | None = None
58
- body_preview: Any = None
59
- error: str | None = None
60
- note: str = ""
61
-
62
-
63
- def openai_candidates() -> list[Candidate]:
64
- base_headers = {"OpenAI-Beta": "assistants=v2"}
65
- return [
66
- Candidate("models.list", OPENAI_BASE_URL, "GET", "/models", "openai"),
67
- Candidate("files.list", OPENAI_BASE_URL, "GET", "/files", "openai", {"limit": 1}),
68
- Candidate("vector_stores.list", OPENAI_BASE_URL, "GET", "/vector_stores", "openai", {"limit": 1}, base_headers),
69
- Candidate("assistants.list", OPENAI_BASE_URL, "GET", "/assistants", "openai", {"limit": 1}, base_headers),
70
- Candidate("threads.probe", OPENAI_BASE_URL, "OPTIONS", "/threads", "openai", headers=base_headers),
71
- Candidate("responses.probe", OPENAI_BASE_URL, "OPTIONS", "/responses", "openai"),
72
- Candidate("responses.compact.probe", OPENAI_BASE_URL, "OPTIONS", "/responses/compact", "openai"),
73
- Candidate("realtime.calls.probe", OPENAI_BASE_URL, "OPTIONS", "/realtime/calls", "openai", headers={"OpenAI-Beta": "realtime=v1"}),
74
- Candidate("batches.list", OPENAI_BASE_URL, "GET", "/batches", "openai", {"limit": 1}),
75
- Candidate("uploads.probe", OPENAI_BASE_URL, "OPTIONS", "/uploads", "openai"),
76
- Candidate("containers.list", OPENAI_BASE_URL, "GET", "/containers", "openai", {"limit": 1}),
77
- Candidate("evals.list", OPENAI_BASE_URL, "GET", "/evals", "openai", {"limit": 1}),
78
- Candidate("fine_tuning.jobs.list", OPENAI_BASE_URL, "GET", "/fine_tuning/jobs", "openai", {"limit": 1}),
79
- Candidate("organization.costs", OPENAI_BASE_URL, "GET", "/organization/costs", "openai", {"limit": 1}),
80
- Candidate("organization.usage.completions", OPENAI_BASE_URL, "GET", "/organization/usage/completions", "openai", {"limit": 1}),
81
- Candidate("organization.usage.responses", OPENAI_BASE_URL, "GET", "/organization/usage/responses", "openai", {"limit": 1}),
82
- Candidate("organization.usage.vector_stores", OPENAI_BASE_URL, "GET", "/organization/usage/vector_stores", "openai", {"limit": 1}),
83
- Candidate("moderations.probe", OPENAI_BASE_URL, "OPTIONS", "/moderations", "openai"),
84
- Candidate("images.probe", OPENAI_BASE_URL, "OPTIONS", "/images/generations", "openai"),
85
- Candidate("audio.speech.probe", OPENAI_BASE_URL, "OPTIONS", "/audio/speech", "openai"),
86
- Candidate("audio.transcriptions.probe", OPENAI_BASE_URL, "OPTIONS", "/audio/transcriptions", "openai"),
87
- Candidate("chat.completions.probe", OPENAI_BASE_URL, "OPTIONS", "/chat/completions", "openai"),
88
- Candidate("embeddings.probe", OPENAI_BASE_URL, "OPTIONS", "/embeddings", "openai"),
89
- ]
90
-
91
-
92
- def chatgpt_candidates() -> list[Candidate]:
93
- return [
94
- Candidate("codex.models", CODEX_BASE_URL, "GET", "/models", "chatgpt", {"client_version": "0.3.0"}),
95
- Candidate("codex.responses.probe", CODEX_BASE_URL, "OPTIONS", "/responses", "chatgpt"),
96
- Candidate("codex.responses.compact.probe", CODEX_BASE_URL, "OPTIONS", "/responses/compact", "chatgpt"),
97
- Candidate("codex.realtime.calls.probe", CODEX_BASE_URL, "OPTIONS", "/realtime/calls", "chatgpt"),
98
- Candidate("codex.memories.trace_summarize.probe", CODEX_BASE_URL, "OPTIONS", "/memories/trace_summarize", "chatgpt"),
99
- Candidate("wham.usage", WHAM_BASE_URL, "GET", "/wham/usage", "chatgpt"),
100
- Candidate("wham.config.requirements", WHAM_BASE_URL, "GET", "/wham/config/requirements", "chatgpt"),
101
- Candidate("wham.tasks.list", WHAM_BASE_URL, "GET", "/wham/tasks/list", "chatgpt", {"limit": 1}),
102
- Candidate("wham.remote.control.enroll.probe", WHAM_BASE_URL, "OPTIONS", "/wham/remote/control/server/enroll", "chatgpt"),
103
- ]
104
-
105
-
106
- def build_headers(store: TokenStore, auth: str, extra: dict[str, str] | None) -> dict[str, str]:
107
- if auth == "openai":
108
- if not store.openai_api_key:
109
- raise RuntimeError("No openai_api_key in auth store. Run authenticate(request_api_key=True).")
110
- headers = {"Authorization": f"Bearer {store.openai_api_key}"}
111
- elif auth == "chatgpt":
112
- headers = {
113
- "Authorization": f"Bearer {store.access_token}",
114
- "originator": ORIGINATOR,
115
- "OpenAI-Beta": "responses=experimental",
116
- }
117
- if store.account_id:
118
- headers["ChatGPT-Account-ID"] = store.account_id
119
- else:
120
- raise ValueError(f"Unknown auth mode: {auth}")
121
-
122
- headers.update(extra or {})
123
- return headers
124
-
125
-
126
- def categorize(status_code: int | None, error: str | None = None) -> str:
127
- if error is not None:
128
- return "error"
129
- if status_code is None:
130
- return "error"
131
- if 200 <= status_code < 300:
132
- return "available"
133
- if status_code == 401:
134
- return "unauthenticated"
135
- if status_code == 403:
136
- return "forbidden_or_plan_gated"
137
- if status_code == 404:
138
- return "not_found_or_hidden"
139
- if status_code == 405:
140
- return "exists_method_not_allowed"
141
- if status_code == 429:
142
- return "rate_limited"
143
- if 400 <= status_code < 500:
144
- return "client_rejected"
145
- if 500 <= status_code < 600:
146
- return "server_error"
147
- return "other"
148
-
149
-
150
- def preview_body(response: requests.Response) -> Any:
151
- text = response.text[:1000]
152
- content_type = response.headers.get("content-type", "")
153
- if "json" not in content_type:
154
- return text
155
- try:
156
- data = response.json()
157
- except ValueError:
158
- return text
159
- if isinstance(data, dict):
160
- return redact(data)
161
- return data
162
-
163
-
164
- def redact(value: Any) -> Any:
165
- if isinstance(value, dict):
166
- redacted: dict[str, Any] = {}
167
- for key, item in value.items():
168
- lowered = key.lower()
169
- if any(secret in lowered for secret in ("token", "secret", "key", "email", "account_id", "user_id")):
170
- redacted[key] = "[redacted]"
171
- else:
172
- redacted[key] = redact(item)
173
- return redacted
174
- if isinstance(value, list):
175
- return [redact(item) for item in value]
176
- return value
177
-
178
-
179
- def probe(candidate: Candidate, store: TokenStore, timeout: float) -> ProbeResult:
180
- url = urljoin(candidate.base.rstrip("/") + "/", candidate.path.lstrip("/"))
181
- start = time.monotonic()
182
- try:
183
- response = requests.request(
184
- candidate.method,
185
- url,
186
- params=candidate.params,
187
- json=candidate.body,
188
- headers=build_headers(store, candidate.auth, candidate.headers),
189
- timeout=timeout,
190
- )
191
- elapsed_ms = int((time.monotonic() - start) * 1000)
192
- return ProbeResult(
193
- name=candidate.name,
194
- base=candidate.base,
195
- method=candidate.method,
196
- path=candidate.path,
197
- url=response.url,
198
- auth=candidate.auth,
199
- status_code=response.status_code,
200
- category=categorize(response.status_code),
201
- elapsed_ms=elapsed_ms,
202
- content_type=response.headers.get("content-type"),
203
- request_id=response.headers.get("x-request-id") or response.headers.get("openai-processing-ms"),
204
- allow=response.headers.get("allow"),
205
- body_preview=preview_body(response),
206
- note=candidate.note,
207
- )
208
- except Exception as exc:
209
- elapsed_ms = int((time.monotonic() - start) * 1000)
210
- return ProbeResult(
211
- name=candidate.name,
212
- base=candidate.base,
213
- method=candidate.method,
214
- path=candidate.path,
215
- url=url,
216
- auth=candidate.auth,
217
- status_code=None,
218
- category=categorize(None, str(exc)),
219
- elapsed_ms=elapsed_ms,
220
- error=f"{type(exc).__name__}: {exc}",
221
- note=candidate.note,
222
- )
223
-
224
-
225
- def select_candidates(scope: str, include_mutating: bool) -> list[Candidate]:
226
- candidates: list[Candidate] = []
227
- if scope in {"all", "openai"}:
228
- candidates.extend(openai_candidates())
229
- if scope in {"all", "chatgpt"}:
230
- candidates.extend(chatgpt_candidates())
231
- if not include_mutating:
232
- candidates = [candidate for candidate in candidates if not candidate.mutating]
233
- return candidates
234
-
235
-
236
- def main() -> int:
237
- parser = argparse.ArgumentParser(description=__doc__)
238
- parser.add_argument("--scope", choices=["all", "openai", "chatgpt"], default="all")
239
- parser.add_argument("--output", type=Path, default=Path("endpoint-probe-results.json"))
240
- parser.add_argument("--timeout", type=float, default=20)
241
- parser.add_argument("--sleep", type=float, default=0.25, help="Delay between probes in seconds.")
242
- parser.add_argument("--include-mutating", action="store_true", help="Include candidates marked mutating.")
243
- args = parser.parse_args()
244
-
245
- store = load_tokens()
246
- if store is None:
247
- raise SystemExit("No Codex auth store found. Run `python -c 'from codex_backend_sdk import OpenAI; OpenAI().authenticate()'` first.")
248
-
249
- results: list[ProbeResult] = []
250
- for candidate in select_candidates(args.scope, args.include_mutating):
251
- result = probe(candidate, store, args.timeout)
252
- results.append(result)
253
- print(f"{result.category:25} {result.status_code or '-':>3} {candidate.method:7} {candidate.auth:7} {candidate.path} ({candidate.name})")
254
- if args.sleep:
255
- time.sleep(args.sleep)
256
-
257
- payload = {
258
- "generated_at": int(time.time()),
259
- "scope": args.scope,
260
- "read_only_default": not args.include_mutating,
261
- "results": [asdict(result) for result in results],
262
- }
263
- args.output.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
264
- print(f"\nWrote {args.output}")
265
- return 0
266
-
267
-
268
- if __name__ == "__main__":
269
- raise SystemExit(main())