codex-backend-sdk 0.3.1__tar.gz → 0.3.2__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.1 → codex_backend_sdk-0.3.2}/PKG-INFO +16 -9
  2. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/README.md +15 -8
  3. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/__init__.py +9 -1
  4. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/_client.py +84 -13
  5. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/_models.py +19 -0
  6. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/codex_client.py +8 -0
  7. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/resources/codex.py +11 -6
  8. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/resources/models.py +3 -2
  9. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/docs/backend-api.md +7 -3
  10. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/pyproject.toml +1 -1
  11. codex_backend_sdk-0.3.2/tests/test_client_retry.py +97 -0
  12. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_codex_resources.py +12 -8
  13. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_responses_resource.py +27 -22
  14. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/.gitignore +0 -0
  15. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/LICENSE +0 -0
  16. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/_streaming.py +0 -0
  17. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/_utils.py +0 -0
  18. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/oauth.py +0 -0
  19. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/pkce.py +0 -0
  20. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/resources/__init__.py +0 -0
  21. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/resources/files.py +0 -0
  22. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  23. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/resources/realtime.py +0 -0
  24. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/resources/responses.py +0 -0
  25. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/codex_backend_sdk/storage.py +0 -0
  26. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/examples/agent.py +0 -0
  27. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/conftest.py +0 -0
  28. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_basic.py +0 -0
  29. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_conversation.py +0 -0
  30. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_files_resource.py +0 -0
  31. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_openai_oauth_resources.py +0 -0
  32. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_realtime_resource.py +0 -0
  33. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_reasoning.py +0 -0
  34. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/tests/test_structured_output.py +0 -0
  35. {codex_backend_sdk-0.3.1 → codex_backend_sdk-0.3.2}/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.1
3
+ Version: 0.3.2
4
4
  Summary: Unofficial Python SDK for the ChatGPT Codex backend API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -270,10 +270,11 @@ as opaque backend state.
270
270
 
271
271
  `client.models.list()` and `client.models.retrieve(model)` mirror the official
272
272
  OpenAI models resource, while preserving Codex-specific metadata as extra
273
- Pydantic fields.
273
+ Pydantic fields. The returned page also exposes the backend `ETag` when present.
274
274
 
275
275
  ```python
276
276
  models = client.models.list()
277
+ print(models.etag)
277
278
  for model in models:
278
279
  print(
279
280
  model.id,
@@ -413,23 +414,29 @@ These methods return raw backend dictionaries because these payloads can contain
413
414
  personal account-specific fields and may change without notice.
414
415
 
415
416
  `client.codex.memories.trace_summarize(...)` exposes the Codex memory
416
- summarization endpoint used by the official client. It returns the raw backend
417
- dictionary:
417
+ summarization endpoint used by the official client. It accepts dictionaries or
418
+ `RawMemory` objects and returns a typed `MemorySummarizeResponse`:
418
419
 
419
420
  ```python
421
+ from codex_backend_sdk import RawMemory
422
+
420
423
  summary = client.codex.memories.trace_summarize(
421
424
  model="gpt-5.4",
422
425
  traces=[
423
- {
424
- "id": "trace_1",
425
- "metadata": {"source_path": "memory.jsonl"},
426
- "items": [{"type": "message", "content": "Remember this"}],
427
- }
426
+ RawMemory(
427
+ id="trace_1",
428
+ metadata={"source_path": "memory.jsonl"},
429
+ items=[{"type": "message", "content": "Remember this"}],
430
+ )
428
431
  ],
429
432
  reasoning={"effort": "low"},
430
433
  )
434
+ print(summary.output[0].memory_summary)
431
435
  ```
432
436
 
437
+ Transient HTTP failures (`429`, `5xx`, timeouts, and connection errors) are
438
+ retried by default. Configure this with `OpenAI(max_retries=..., retry_base_delay=...)`.
439
+
433
440
  ### File Uploads
434
441
 
435
442
  `client.files.upload(...)` follows the official Codex file flow for Apps/MCP
@@ -244,10 +244,11 @@ as opaque backend state.
244
244
 
245
245
  `client.models.list()` and `client.models.retrieve(model)` mirror the official
246
246
  OpenAI models resource, while preserving Codex-specific metadata as extra
247
- Pydantic fields.
247
+ Pydantic fields. The returned page also exposes the backend `ETag` when present.
248
248
 
249
249
  ```python
250
250
  models = client.models.list()
251
+ print(models.etag)
251
252
  for model in models:
252
253
  print(
253
254
  model.id,
@@ -387,23 +388,29 @@ These methods return raw backend dictionaries because these payloads can contain
387
388
  personal account-specific fields and may change without notice.
388
389
 
389
390
  `client.codex.memories.trace_summarize(...)` exposes the Codex memory
390
- summarization endpoint used by the official client. It returns the raw backend
391
- dictionary:
391
+ summarization endpoint used by the official client. It accepts dictionaries or
392
+ `RawMemory` objects and returns a typed `MemorySummarizeResponse`:
392
393
 
393
394
  ```python
395
+ from codex_backend_sdk import RawMemory
396
+
394
397
  summary = client.codex.memories.trace_summarize(
395
398
  model="gpt-5.4",
396
399
  traces=[
397
- {
398
- "id": "trace_1",
399
- "metadata": {"source_path": "memory.jsonl"},
400
- "items": [{"type": "message", "content": "Remember this"}],
401
- }
400
+ RawMemory(
401
+ id="trace_1",
402
+ metadata={"source_path": "memory.jsonl"},
403
+ items=[{"type": "message", "content": "Remember this"}],
404
+ )
402
405
  ],
403
406
  reasoning={"effort": "low"},
404
407
  )
408
+ print(summary.output[0].memory_summary)
405
409
  ```
406
410
 
411
+ Transient HTTP failures (`429`, `5xx`, timeouts, and connection errors) are
412
+ retried by default. Configure this with `OpenAI(max_retries=..., retry_base_delay=...)`.
413
+
407
414
  ### File Uploads
408
415
 
409
416
  `client.files.upload(...)` follows the official Codex file flow for Apps/MCP
@@ -17,7 +17,7 @@ Quickstart:
17
17
  print(response.output_text)
18
18
  """
19
19
 
20
- __version__ = "0.3.1"
20
+ __version__ = "0.3.2"
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
@@ -33,7 +33,11 @@ from .codex_client import (
33
33
  Verbosity,
34
34
  CodexClient,
35
35
  OpenAI,
36
+ MemorySummarizeOutput,
37
+ MemorySummarizeResponse,
36
38
  Model,
39
+ RawMemory,
40
+ RawMemoryMetadata,
37
41
  Response,
38
42
  ResponseStreamEvent,
39
43
  ResponseUsage,
@@ -54,7 +58,11 @@ __all__ = [
54
58
  "CreateEmbeddingResponse",
55
59
  "Embedding",
56
60
  "EmbeddingUsage",
61
+ "MemorySummarizeOutput",
62
+ "MemorySummarizeResponse",
57
63
  "Model",
64
+ "RawMemory",
65
+ "RawMemoryMetadata",
58
66
  "Response",
59
67
  "ResponseStreamEvent",
60
68
  "ResponseUsage",
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import urllib.parse
6
+ import time
6
7
  from typing import Any, Optional
7
8
 
8
9
  import requests
@@ -32,6 +33,8 @@ class CodexClient:
32
33
  model: str = "gpt-5.4",
33
34
  instructions: Optional[str] = None,
34
35
  timeout: float = 120,
36
+ max_retries: int = 2,
37
+ retry_base_delay: float = 0.25,
35
38
  ) -> None:
36
39
  from .resources.codex import CodexResources
37
40
  from .resources.models import Models
@@ -42,6 +45,8 @@ class CodexClient:
42
45
 
43
46
  self._store = store
44
47
  self._timeout = timeout
48
+ self._max_retries = max_retries
49
+ self._retry_base_delay = retry_base_delay
45
50
  self._session = requests.Session()
46
51
  self._defaults = {
47
52
  "model": model,
@@ -121,14 +126,30 @@ class CodexClient:
121
126
  return False
122
127
 
123
128
  def _get(self, path: str, *, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
129
+ return self._get_raw(path, params=params).json()
130
+
131
+ def _get_raw(
132
+ self,
133
+ path: str,
134
+ *,
135
+ params: Optional[dict[str, Any]] = None,
136
+ headers: Optional[dict[str, str]] = None,
137
+ timeout: Any = _UNSET,
138
+ ) -> requests.Response:
124
139
  self._ensure_auth()
125
- response = self._session.get(f"{BASE_URL}{path}", params=params, timeout=self._timeout)
126
- response.raise_for_status()
127
- return response.json()
140
+ response = self._request_with_retries(
141
+ "GET",
142
+ f"{BASE_URL}{path}",
143
+ params=params,
144
+ headers=headers,
145
+ timeout=self._timeout if not _is_given(timeout) else timeout,
146
+ )
147
+ return response
128
148
 
129
149
  def _post(self, path: str, *, body: dict[str, Any], stream: bool = False) -> requests.Response:
130
150
  self._ensure_auth()
131
- response = self._session.post(
151
+ response = self._request_with_retries(
152
+ "POST",
132
153
  f"{BASE_URL}{path}",
133
154
  json=body,
134
155
  headers={"Accept": "text/event-stream"} if stream else None,
@@ -151,7 +172,8 @@ class CodexClient:
151
172
  timeout: Any = _UNSET,
152
173
  ) -> requests.Response:
153
174
  self._ensure_auth()
154
- response = self._session.post(
175
+ response = self._request_with_retries(
176
+ "POST",
155
177
  f"{BASE_URL}{path}",
156
178
  json=body if files is None and data is None and content is None else None,
157
179
  data=content if content is not None else data,
@@ -178,12 +200,14 @@ class CodexClient:
178
200
  params: Optional[dict[str, Any]] = None,
179
201
  timeout: Any = _UNSET,
180
202
  ) -> dict[str, Any]:
181
- response = requests.post(
203
+ response = self._request_with_retries(
204
+ "POST",
182
205
  f"{OPENAI_BASE_URL}{path}",
183
206
  json=body,
184
207
  headers=self._openai_headers(headers),
185
208
  params=params,
186
209
  timeout=self._timeout if not _is_given(timeout) else timeout,
210
+ _use_session=False,
187
211
  )
188
212
  response.raise_for_status()
189
213
  return response.json()
@@ -200,7 +224,8 @@ class CodexClient:
200
224
  timeout: Any = _UNSET,
201
225
  stream: bool = False,
202
226
  ) -> requests.Response:
203
- response = requests.post(
227
+ response = self._request_with_retries(
228
+ "POST",
204
229
  f"{OPENAI_BASE_URL}{path}",
205
230
  json=body if files is None and data is None else None,
206
231
  data=data,
@@ -209,20 +234,24 @@ class CodexClient:
209
234
  params=params,
210
235
  stream=stream,
211
236
  timeout=self._timeout if not _is_given(timeout) else timeout,
237
+ _use_session=False,
212
238
  )
213
239
  response.raise_for_status()
214
240
  return response
215
241
 
216
242
  def _get_wham(self, path: str, *, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
217
243
  self._ensure_auth()
218
- response = self._session.get(f"{WHAM_BASE_URL}{path}", params=params, timeout=30)
219
- response.raise_for_status()
244
+ response = self._request_with_retries(
245
+ "GET",
246
+ f"{WHAM_BASE_URL}{path}",
247
+ params=params,
248
+ timeout=30,
249
+ )
220
250
  return response.json()
221
251
 
222
252
  def _get_chatgpt(self, path: str) -> dict[str, Any]:
223
253
  self._ensure_auth()
224
- response = self._session.get(f"{CHATGPT_BASE_URL}{path}", timeout=30)
225
- response.raise_for_status()
254
+ response = self._request_with_retries("GET", f"{CHATGPT_BASE_URL}{path}", timeout=30)
226
255
  return response.json()
227
256
 
228
257
  def _post_chatgpt(
@@ -233,14 +262,47 @@ class CodexClient:
233
262
  timeout: Any = _UNSET,
234
263
  ) -> dict[str, Any]:
235
264
  self._ensure_auth()
236
- response = self._session.post(
265
+ response = self._request_with_retries(
266
+ "POST",
237
267
  f"{CHATGPT_BASE_URL}{path}",
238
268
  json=body,
239
269
  timeout=self._timeout if not _is_given(timeout) else timeout,
240
270
  )
241
- response.raise_for_status()
242
271
  return response.json()
243
272
 
273
+ def _request_with_retries(self, method: str, url: str, **kwargs: Any) -> requests.Response:
274
+ last_error: requests.RequestException | None = None
275
+ use_session = kwargs.pop("_use_session", True)
276
+ for attempt in range(self._max_retries + 1):
277
+ try:
278
+ request = self._session.request if use_session else requests.request
279
+ response = request(method, url, **kwargs)
280
+ if self._should_retry_response(response, attempt):
281
+ self._sleep_before_retry(response, attempt)
282
+ continue
283
+ response.raise_for_status()
284
+ return response
285
+ except (requests.Timeout, requests.ConnectionError) as exc:
286
+ last_error = exc
287
+ if attempt >= self._max_retries:
288
+ raise
289
+ self._sleep_before_retry(None, attempt)
290
+ if last_error is not None:
291
+ raise last_error
292
+ raise RuntimeError("Request retry loop exhausted")
293
+
294
+ def _should_retry_response(self, response: requests.Response, attempt: int) -> bool:
295
+ if attempt >= self._max_retries:
296
+ return False
297
+ return response.status_code == 429 or 500 <= response.status_code < 600
298
+
299
+ def _sleep_before_retry(self, response: requests.Response | None, attempt: int) -> None:
300
+ retry_after = response.headers.get("Retry-After") if response is not None else None
301
+ delay = _parse_retry_after(retry_after)
302
+ if delay is None:
303
+ delay = self._retry_base_delay * (2 ** attempt)
304
+ time.sleep(delay)
305
+
244
306
  def realtime_websocket_url(self, *, model: str) -> str:
245
307
  """Return the official OpenAI Realtime WebSocket URL for Codex plugins."""
246
308
  if not model:
@@ -262,3 +324,12 @@ class CodexClient:
262
324
 
263
325
 
264
326
  OpenAI = CodexClient
327
+
328
+
329
+ def _parse_retry_after(value: str | None) -> float | None:
330
+ if not value:
331
+ return None
332
+ try:
333
+ return max(0.0, float(value))
334
+ except ValueError:
335
+ return None
@@ -173,6 +173,25 @@ class Transcription(CodexBaseModel):
173
173
  text: str = ""
174
174
 
175
175
 
176
+ class RawMemoryMetadata(CodexBaseModel):
177
+ source_path: str
178
+
179
+
180
+ class RawMemory(CodexBaseModel):
181
+ id: str
182
+ metadata: RawMemoryMetadata
183
+ items: list[Any] = Field(default_factory=list)
184
+
185
+
186
+ class MemorySummarizeOutput(CodexBaseModel):
187
+ raw_memory: str = Field(default="", alias="trace_summary")
188
+ memory_summary: str = ""
189
+
190
+
191
+ class MemorySummarizeResponse(CodexBaseModel):
192
+ output: list[MemorySummarizeOutput] = Field(default_factory=list)
193
+
194
+
176
195
  class UploadedFile(CodexBaseModel):
177
196
  file_id: str
178
197
  uri: str
@@ -14,6 +14,10 @@ from ._models import (
14
14
  Embedding,
15
15
  EmbeddingUsage,
16
16
  Model,
17
+ MemorySummarizeOutput,
18
+ MemorySummarizeResponse,
19
+ RawMemory,
20
+ RawMemoryMetadata,
17
21
  ReasoningEffort,
18
22
  ReasoningSummary,
19
23
  RealtimeCallResponse,
@@ -37,8 +41,12 @@ __all__ = [
37
41
  "Embedding",
38
42
  "EmbeddingUsage",
39
43
  "CompactedResponse",
44
+ "MemorySummarizeOutput",
45
+ "MemorySummarizeResponse",
40
46
  "Model",
41
47
  "OpenAI",
48
+ "RawMemory",
49
+ "RawMemoryMetadata",
42
50
  "ReasoningEffort",
43
51
  "ReasoningSummary",
44
52
  "Response",
@@ -4,6 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, TYPE_CHECKING
6
6
 
7
+ from .._models import MemorySummarizeResponse
8
+ from .._utils import _jsonable
9
+
7
10
  if TYPE_CHECKING:
8
11
  from .._client import CodexClient
9
12
 
@@ -36,15 +39,17 @@ class CodexMemories:
36
39
  self,
37
40
  *,
38
41
  model: str,
39
- traces: list[dict[str, Any]],
40
- reasoning: dict[str, Any] | None = None,
41
- ) -> dict[str, Any]:
42
+ traces: list[Any],
43
+ reasoning: Any = None,
44
+ ) -> MemorySummarizeResponse:
42
45
  if not model:
43
46
  raise ValueError(f"Expected a non-empty value for `model` but received {model!r}")
44
- payload: dict[str, Any] = {"model": model, "traces": traces}
47
+ payload: dict[str, Any] = {"model": model, "traces": _jsonable(traces)}
45
48
  if reasoning is not None:
46
- payload["reasoning"] = reasoning
47
- return self._client._post("/memories/trace_summarize", body=payload).json()
49
+ payload["reasoning"] = _jsonable(reasoning)
50
+ return MemorySummarizeResponse.model_validate(
51
+ self._client._post("/memories/trace_summarize", body=payload).json()
52
+ )
48
53
 
49
54
 
50
55
  class CodexConfig:
@@ -36,10 +36,11 @@ class Models:
36
36
  if time.time() - self._cache_fetched_at <= _CACHE_TTL:
37
37
  return self._cache
38
38
 
39
- data = self._client._get("/models", params={"client_version": CLIENT_VERSION})
39
+ response = self._client._get_raw("/models", params={"client_version": CLIENT_VERSION})
40
+ data = response.json()
40
41
  models = [_model_from_backend(item) for item in data.get("models", [])]
41
42
  models.sort(key=lambda model: getattr(model, "priority", 0))
42
- page = SyncPage(data=models)
43
+ page = SyncPage(data=models, etag=response.headers.get("ETag"))
43
44
  self._cache = page
44
45
  self._cache_fetched_at = time.time()
45
46
  return page
@@ -47,6 +47,9 @@ List models available to this account.
47
47
 
48
48
  **Response** — JSON `{ "models": [ ModelObject, … ] }`
49
49
 
50
+ The backend may include an `ETag` header. The SDK preserves it as
51
+ `client.models.list().etag`.
52
+
50
53
  Key fields per model:
51
54
  | Field | Type | Notes |
52
55
  |---|---|---|
@@ -228,8 +231,8 @@ Summarize traces into persistent memories.
228
231
 
229
232
  **SDK method**: `client.codex.memories.trace_summarize(...)`
230
233
 
231
- **Status**: Supported as a raw helper. May return `403 Forbidden` depending on
232
- plan/account capabilities.
234
+ **Status**: Supported as a typed helper. May return `403 Forbidden` depending
235
+ on plan/account capabilities.
233
236
 
234
237
  **Request body**:
235
238
  ```json
@@ -246,7 +249,8 @@ plan/account capabilities.
246
249
  }
247
250
  ```
248
251
 
249
- **Response** — raw JSON, typically `{ "output": [...] }`.
252
+ **Response** — JSON `{ "output": [...] }`, exposed by the SDK as
253
+ `MemorySummarizeResponse`.
250
254
 
251
255
  ---
252
256
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-backend-sdk"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Unofficial Python SDK for the ChatGPT Codex backend API"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,97 @@
1
+ import requests
2
+
3
+ import codex_backend_sdk._client as client_module
4
+ from codex_backend_sdk import OpenAI
5
+
6
+
7
+ class DummySession:
8
+ def __init__(self, outcomes):
9
+ self.outcomes = list(outcomes)
10
+ self.calls = []
11
+
12
+ def request(self, method, url, **kwargs):
13
+ self.calls.append((method, url, kwargs))
14
+ outcome = self.outcomes.pop(0)
15
+ if isinstance(outcome, Exception):
16
+ raise outcome
17
+ return outcome
18
+
19
+
20
+ class RetryClient(OpenAI):
21
+ def __init__(self, outcomes, **kwargs):
22
+ super().__init__(max_retries=kwargs.pop("max_retries", 2), retry_base_delay=0, **kwargs)
23
+ self._session = DummySession(outcomes)
24
+
25
+ def _ensure_auth(self):
26
+ return None
27
+
28
+
29
+ def _response(status_code, *, body=b"{}", headers=None):
30
+ response = requests.Response()
31
+ response.status_code = status_code
32
+ response._content = body
33
+ response.headers.update(headers or {})
34
+ response.url = "https://example.test"
35
+ return response
36
+
37
+
38
+ def test_retry_retries_5xx_then_returns_success(monkeypatch):
39
+ sleeps = []
40
+ monkeypatch.setattr(client_module.time, "sleep", sleeps.append)
41
+ client = RetryClient([
42
+ _response(503),
43
+ _response(200, body=b'{"ok": true}'),
44
+ ])
45
+
46
+ response = client._get_raw("/models")
47
+
48
+ assert response.json() == {"ok": True}
49
+ assert len(client._session.calls) == 2
50
+ assert sleeps == [0]
51
+
52
+
53
+ def test_retry_honors_retry_after_header(monkeypatch):
54
+ sleeps = []
55
+ monkeypatch.setattr(client_module.time, "sleep", sleeps.append)
56
+ client = RetryClient([
57
+ _response(429, headers={"Retry-After": "1.5"}),
58
+ _response(200),
59
+ ])
60
+
61
+ client._get_raw("/models")
62
+
63
+ assert len(client._session.calls) == 2
64
+ assert sleeps == [1.5]
65
+
66
+
67
+ def test_retry_does_not_retry_client_errors(monkeypatch):
68
+ sleeps = []
69
+ monkeypatch.setattr(client_module.time, "sleep", sleeps.append)
70
+ client = RetryClient([
71
+ _response(400, body=b'{"error": "bad request"}'),
72
+ ])
73
+
74
+ try:
75
+ client._get_raw("/models")
76
+ except requests.HTTPError:
77
+ pass
78
+ else:
79
+ raise AssertionError("Expected HTTPError")
80
+
81
+ assert len(client._session.calls) == 1
82
+ assert sleeps == []
83
+
84
+
85
+ def test_retry_retries_transport_timeout(monkeypatch):
86
+ sleeps = []
87
+ monkeypatch.setattr(client_module.time, "sleep", sleeps.append)
88
+ client = RetryClient([
89
+ requests.Timeout("temporary timeout"),
90
+ _response(200, body=b'{"ok": true}'),
91
+ ])
92
+
93
+ response = client._get_raw("/models")
94
+
95
+ assert response.json() == {"ok": True}
96
+ assert len(client._session.calls) == 2
97
+ assert sleeps == [0]
@@ -1,4 +1,4 @@
1
- from codex_backend_sdk import OpenAI
1
+ from codex_backend_sdk import MemorySummarizeResponse, OpenAI, RawMemory
2
2
 
3
3
 
4
4
  class FakeCodexClient(OpenAI):
@@ -41,7 +41,9 @@ class FakeCodexClient(OpenAI):
41
41
  def _post(self, path, *, body, stream=False):
42
42
  self.posts.append((path, body, stream))
43
43
  if path == "/memories/trace_summarize":
44
- return FakeJSONResponse({"output": [{"memory_summary": "summary"}]})
44
+ return FakeJSONResponse({
45
+ "output": [{"trace_summary": "raw", "memory_summary": "summary"}]
46
+ })
45
47
  raise AssertionError(f"Unexpected Codex post path: {path}")
46
48
 
47
49
 
@@ -77,16 +79,18 @@ def test_codex_memories_trace_summarize_posts_codex_payload():
77
79
  response = client.codex.memories.trace_summarize(
78
80
  model="gpt-test",
79
81
  traces=[
80
- {
81
- "id": "trace_1",
82
- "metadata": {"source_path": "/tmp/memory.jsonl"},
83
- "items": [{"type": "message", "content": "remember me"}],
84
- }
82
+ RawMemory(
83
+ id="trace_1",
84
+ metadata={"source_path": "/tmp/memory.jsonl"},
85
+ items=[{"type": "message", "content": "remember me"}],
86
+ )
85
87
  ],
86
88
  reasoning={"effort": "low"},
87
89
  )
88
90
 
89
- assert response == {"output": [{"memory_summary": "summary"}]}
91
+ assert isinstance(response, MemorySummarizeResponse)
92
+ assert response.output[0].raw_memory == "raw"
93
+ assert response.output[0].memory_summary == "summary"
90
94
  assert client.posts == [
91
95
  (
92
96
  "/memories/trace_summarize",
@@ -15,8 +15,9 @@ class FakeSSE:
15
15
 
16
16
 
17
17
  class FakeJSONResponse:
18
- def __init__(self, payload):
18
+ def __init__(self, payload, headers=None):
19
19
  self._payload = payload
20
+ self.headers = headers or {}
20
21
 
21
22
  def json(self):
22
23
  return self._payload
@@ -47,28 +48,31 @@ class FakeClient(OpenAI):
47
48
  ),
48
49
  ])
49
50
 
50
- def _get(self, path, *, params=None):
51
+ def _get_raw(self, path, *, params=None, headers=None, timeout=None):
51
52
  self.gets.append((path, params))
52
- return {
53
- "models": [
54
- {
55
- "slug": "gpt-lower-priority",
56
- "display_name": "GPT Lower Priority",
57
- "description": "Test model",
58
- "context_window": 123,
59
- "supported_in_api": True,
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,
69
- }
70
- ]
71
- }
53
+ return FakeJSONResponse(
54
+ {
55
+ "models": [
56
+ {
57
+ "slug": "gpt-lower-priority",
58
+ "display_name": "GPT Lower Priority",
59
+ "description": "Test model",
60
+ "context_window": 123,
61
+ "supported_in_api": True,
62
+ "priority": 7,
63
+ },
64
+ {
65
+ "slug": "gpt-test",
66
+ "display_name": "GPT Test",
67
+ "description": "Test model",
68
+ "context_window": 456,
69
+ "supported_in_api": True,
70
+ "priority": 1,
71
+ },
72
+ ]
73
+ },
74
+ headers={"ETag": "models-etag"},
75
+ )
72
76
 
73
77
 
74
78
  def test_responses_create_collects_to_pydantic_response():
@@ -147,6 +151,7 @@ def test_models_resource_returns_iterable_page():
147
151
  assert len(page) == 2
148
152
  assert model.id == "gpt-test"
149
153
  assert model.context_window == 456
154
+ assert page.etag == "models-etag"
150
155
  assert client.models.retrieve("gpt-test").id == "gpt-test"
151
156
  assert len(client.gets) == 1
152
157