codex-backend-sdk 0.5.3__tar.gz → 0.5.5__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 (76) hide show
  1. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/CHANGELOG.md +25 -0
  2. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/PKG-INFO +40 -4
  3. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/README.md +39 -3
  4. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/__init__.py +1 -1
  5. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/_client.py +3 -0
  6. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/_streaming.py +15 -5
  7. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/_transport.py +21 -1
  8. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/_responses_payloads.py +1 -1
  9. codex_backend_sdk-0.5.5/codex_backend_sdk/resources/_responses_routing.py +45 -0
  10. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/codex.py +4 -5
  11. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/realtime.py +3 -17
  12. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/responses.py +88 -19
  13. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/docs/api-reference.md +6 -0
  14. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/docs/backend-api.md +11 -5
  15. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/pyproject.toml +1 -1
  16. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_client_retry.py +19 -2
  17. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_codex_resources.py +20 -23
  18. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_realtime_resource.py +22 -20
  19. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_responses_resource.py +53 -0
  20. codex_backend_sdk-0.5.5/tests/test_responses_routing.py +185 -0
  21. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/uv.lock +1 -1
  22. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/.gitignore +0 -0
  23. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/LICENSE +0 -0
  24. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/_models.py +0 -0
  25. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/_utils.py +0 -0
  26. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/codex_client.py +0 -0
  27. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/oauth.py +0 -0
  28. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/pkce.py +0 -0
  29. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/__init__.py +0 -0
  30. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/chatgpt.py +0 -0
  31. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/chatgpt_apps.py +0 -0
  32. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/chatgpt_connectors.py +0 -0
  33. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/chatgpt_plugin_bundles.py +0 -0
  34. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/chatgpt_plugin_shares.py +0 -0
  35. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/chatgpt_plugins.py +0 -0
  36. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/chatgpt_writing_blocks.py +0 -0
  37. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/codex_web_search.py +0 -0
  38. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/files.py +0 -0
  39. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/images.py +0 -0
  40. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/models.py +0 -0
  41. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  42. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/remote_control.py +0 -0
  43. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/responses_websocket.py +0 -0
  44. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/resources/worktree_snapshots.py +0 -0
  45. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/codex_backend_sdk/storage.py +0 -0
  46. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/docs/desktop-endpoint-inventory.md +0 -0
  47. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/docs/endpoint-coverage.md +0 -0
  48. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/docs/openai-compatibility.md +0 -0
  49. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/examples/agent.py +0 -0
  50. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/conftest.py +0 -0
  51. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_account_info.py +0 -0
  52. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_basic.py +0 -0
  53. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_apps.py +0 -0
  54. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_connectors.py +0 -0
  55. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_plugin_bundles.py +0 -0
  56. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_plugin_shares.py +0 -0
  57. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_plugins.py +0 -0
  58. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_projects_gizmos.py +0 -0
  59. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_resources.py +0 -0
  60. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_search_files.py +0 -0
  61. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_chatgpt_writing_blocks.py +0 -0
  62. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_codex_web_search.py +0 -0
  63. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_conversation.py +0 -0
  64. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_files_resource.py +0 -0
  65. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_hosted_mcp.py +0 -0
  66. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_images.py +0 -0
  67. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_oauth.py +0 -0
  68. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_openai_compatibility.py +0 -0
  69. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_openai_oauth_resources.py +0 -0
  70. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_prompt_cache_usage.py +0 -0
  71. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_reasoning.py +0 -0
  72. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_remote_control_resource.py +0 -0
  73. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_responses_websocket.py +0 -0
  74. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_structured_output.py +0 -0
  75. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_tools.py +0 -0
  76. {codex_backend_sdk-0.5.3 → codex_backend_sdk-0.5.5}/tests/test_worktree_snapshots.py +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.5.5] - 2026-10-22
6
+
7
+ ### Added
8
+ - Added `client.responses.turn()` for ephemeral sticky routing within one logical
9
+ HTTP Responses turn, isolated across concurrent turns and invalidated when
10
+ authentication or cache affinity changes.
11
+
12
+ ### Changed
13
+ - Forward Realtime model identifiers unchanged instead of maintaining a local
14
+ allowlist. Newly available Codex models, including `gpt-live-1.1`, no longer
15
+ require an SDK update; the backend determines availability and authorization.
16
+
17
+ ### Fixed
18
+ - Supply the HTTP `session-id` cache-affinity header from `prompt_cache_key`
19
+ unless an explicit request or client header already sets it.
20
+ - Route reset-credit requests through the authenticated WHAM API.
21
+ - Preserve backend remote-compaction errors and close the response stream.
22
+ - Align the exported package version with the distribution metadata.
23
+
24
+ ## [0.5.4] - 2026-09-24
25
+
26
+ ### Fixed
27
+ - Preserve a bounded backend response body in raised HTTP errors so callers can
28
+ diagnose rejected requests without reconstructing the transport call.
29
+
5
30
  ## [0.5.3] - 2026-09-18
6
31
 
7
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codex-backend-sdk
3
- Version: 0.5.3
3
+ Version: 0.5.5
4
4
  Summary: Unofficial Python SDK for the ChatGPT Codex backend API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -51,6 +51,12 @@ The package intentionally separates three kinds of API:
51
51
 
52
52
  ## Install
53
53
 
54
+ ```bash
55
+ pip install --upgrade codex-backend-sdk
56
+ ```
57
+
58
+ For an editable checkout:
59
+
54
60
  ```bash
55
61
  git clone https://github.com/B4PT0R/codex-backend-sdk.git
56
62
  cd codex-backend-sdk
@@ -492,6 +498,31 @@ The ChatGPT read-aloud service is smaller than the public speech API: it exposes
492
498
  language and playback speed, not arbitrary model and voice selection. It can
493
499
  return a typed in-memory object, bytes, `BytesIO`, a data URI, or a file path.
494
500
 
501
+ ### Turn-scoped HTTP routing
502
+
503
+ For an agent turn with several generations (tools or retries), use an isolated
504
+ Responses resource. Continue sending the complete input as usual:
505
+
506
+ ```python
507
+ with client.responses.turn() as turn:
508
+ first = turn.create(input=items, prompt_cache_key="conversation-id")
509
+ # Append model/tool results to items, then generate again in the same turn.
510
+ following = turn.create(input=items, prompt_cache_key="conversation-id")
511
+ ```
512
+
513
+ The resource captures the first `x-codex-turn-state` from HTTP response headers
514
+ or `response.metadata` events and replays it unchanged within this scope. The
515
+ opaque routing token is never added to model input or persisted. Leaving the
516
+ scope closes it; a new turn starts without the previous token. Authentication
517
+ or cache-affinity changes invalidate it. Separate scopes do not share tokens,
518
+ even on the same client. Streaming callers must consume the metadata event for
519
+ an SSE-only token to be captured. Compaction remains a separate backend request
520
+ and does not inherit the turn token; this scope does not manage WebSockets.
521
+
522
+ `prompt_cache_key` also supplies the HTTP `session-id` affinity header unless
523
+ an explicit request/client header overrides it (case-insensitively). These are
524
+ routing mechanisms, not a guarantee of a cache hit or reduced quota usage.
525
+
495
526
  ## Advanced transports
496
527
 
497
528
  ### Realtime voice
@@ -512,9 +543,14 @@ with client.live.sideband.connect(session_id=live.session.id) as sideband:
512
543
  print(event.type)
513
544
  ```
514
545
 
515
- Live probes confirmed `gpt-live-1-codex` and
516
- `gpt-live-1-boulder-alpha`. They are Codex Realtime v3 snapshots, not aliases
517
- for the public `gpt-live-1` model. `client.live` follows the current
546
+ Model identifiers are forwarded unchanged: the backend decides which models
547
+ are available to your account, so new Codex models do not require an SDK update.
548
+ Earlier call-creation probes accepted `gpt-live-1-codex` and
549
+ `gpt-live-1-boulder-alpha`; these examples are not an exhaustive catalog.
550
+ A `gpt-live-1.1` probe on October 22, 2026 also returned an SDP answer and
551
+ connected WebRTC, but the server then rejected the voice session with
552
+ `forbidden: Voice session access denied.` Call creation alone does not prove
553
+ that an account can receive model responses. `client.live` follows the current
518
554
  `openai-python` creation, response, and sideband idioms while adapting them to
519
555
  the ChatGPT OAuth call transport.
520
556
 
@@ -22,6 +22,12 @@ The package intentionally separates three kinds of API:
22
22
 
23
23
  ## Install
24
24
 
25
+ ```bash
26
+ pip install --upgrade codex-backend-sdk
27
+ ```
28
+
29
+ For an editable checkout:
30
+
25
31
  ```bash
26
32
  git clone https://github.com/B4PT0R/codex-backend-sdk.git
27
33
  cd codex-backend-sdk
@@ -463,6 +469,31 @@ The ChatGPT read-aloud service is smaller than the public speech API: it exposes
463
469
  language and playback speed, not arbitrary model and voice selection. It can
464
470
  return a typed in-memory object, bytes, `BytesIO`, a data URI, or a file path.
465
471
 
472
+ ### Turn-scoped HTTP routing
473
+
474
+ For an agent turn with several generations (tools or retries), use an isolated
475
+ Responses resource. Continue sending the complete input as usual:
476
+
477
+ ```python
478
+ with client.responses.turn() as turn:
479
+ first = turn.create(input=items, prompt_cache_key="conversation-id")
480
+ # Append model/tool results to items, then generate again in the same turn.
481
+ following = turn.create(input=items, prompt_cache_key="conversation-id")
482
+ ```
483
+
484
+ The resource captures the first `x-codex-turn-state` from HTTP response headers
485
+ or `response.metadata` events and replays it unchanged within this scope. The
486
+ opaque routing token is never added to model input or persisted. Leaving the
487
+ scope closes it; a new turn starts without the previous token. Authentication
488
+ or cache-affinity changes invalidate it. Separate scopes do not share tokens,
489
+ even on the same client. Streaming callers must consume the metadata event for
490
+ an SSE-only token to be captured. Compaction remains a separate backend request
491
+ and does not inherit the turn token; this scope does not manage WebSockets.
492
+
493
+ `prompt_cache_key` also supplies the HTTP `session-id` affinity header unless
494
+ an explicit request/client header overrides it (case-insensitively). These are
495
+ routing mechanisms, not a guarantee of a cache hit or reduced quota usage.
496
+
466
497
  ## Advanced transports
467
498
 
468
499
  ### Realtime voice
@@ -483,9 +514,14 @@ with client.live.sideband.connect(session_id=live.session.id) as sideband:
483
514
  print(event.type)
484
515
  ```
485
516
 
486
- Live probes confirmed `gpt-live-1-codex` and
487
- `gpt-live-1-boulder-alpha`. They are Codex Realtime v3 snapshots, not aliases
488
- for the public `gpt-live-1` model. `client.live` follows the current
517
+ Model identifiers are forwarded unchanged: the backend decides which models
518
+ are available to your account, so new Codex models do not require an SDK update.
519
+ Earlier call-creation probes accepted `gpt-live-1-codex` and
520
+ `gpt-live-1-boulder-alpha`; these examples are not an exhaustive catalog.
521
+ A `gpt-live-1.1` probe on October 22, 2026 also returned an SDP answer and
522
+ connected WebRTC, but the server then rejected the voice session with
523
+ `forbidden: Voice session access denied.` Call creation alone does not prove
524
+ that an account can receive model responses. `client.live` follows the current
489
525
  `openai-python` creation, response, and sideband idioms while adapting them to
490
526
  the ChatGPT OAuth call transport.
491
527
 
@@ -17,7 +17,7 @@ Quickstart:
17
17
  print(response.output_text)
18
18
  """
19
19
 
20
- __version__ = "0.5.3"
20
+ __version__ = "0.5.5"
21
21
 
22
22
  from .oauth import (
23
23
  DeviceCode,
@@ -48,6 +48,7 @@ class CodexClient:
48
48
  from .resources.responses import Responses
49
49
 
50
50
  self._store = store
51
+ self._auth_revision = 0
51
52
  self._timeout = timeout
52
53
  self._max_retries = max_retries
53
54
  self._retry_base_delay = retry_base_delay
@@ -226,6 +227,7 @@ class CodexClient:
226
227
 
227
228
  cleared = clear_tokens()
228
229
  self._store = None
230
+ self._auth_revision += 1
229
231
  self._session.headers.pop("Authorization", None)
230
232
  self._session.headers.pop("ChatGPT-Account-ID", None)
231
233
  return cleared
@@ -245,6 +247,7 @@ class CodexClient:
245
247
  return self.logout()
246
248
 
247
249
  def _set_store(self, store: TokenStore) -> None:
250
+ self._auth_revision += 1
248
251
  self._store = store
249
252
  self._session.headers.update(self._auth_headers())
250
253
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from collections.abc import Iterator
7
- from typing import Any, Optional
7
+ from typing import Any, Callable, Optional
8
8
 
9
9
  import requests
10
10
 
@@ -14,8 +14,12 @@ from ._models import ResponseStreamEvent
14
14
  class ResponseEventStream(Iterator[ResponseStreamEvent]):
15
15
  """Closeable/context-managed event stream compatible with OpenAI streams."""
16
16
 
17
- def __init__(self, response: requests.Response) -> None:
17
+ def __init__(
18
+ self, response: requests.Response, *,
19
+ on_event: Optional[Callable[[ResponseStreamEvent], None]] = None,
20
+ ) -> None:
18
21
  self.response = response
22
+ self._on_event = on_event
19
23
  self._events = (
20
24
  ResponseStreamEvent.model_validate(payload)
21
25
  for payload in iter_sse_payloads(response)
@@ -25,7 +29,10 @@ class ResponseEventStream(Iterator[ResponseStreamEvent]):
25
29
  return self
26
30
 
27
31
  def __next__(self) -> ResponseStreamEvent:
28
- return next(self._events)
32
+ event = next(self._events)
33
+ if self._on_event is not None:
34
+ self._on_event(event)
35
+ return event
29
36
 
30
37
  def close(self) -> None:
31
38
  close = getattr(self.response, "close", None)
@@ -39,8 +46,11 @@ class ResponseEventStream(Iterator[ResponseStreamEvent]):
39
46
  self.close()
40
47
 
41
48
 
42
- def stream_response_events(response: requests.Response) -> ResponseEventStream:
43
- return ResponseEventStream(response)
49
+ def stream_response_events(
50
+ response: requests.Response, *,
51
+ on_event: Optional[Callable[[ResponseStreamEvent], None]] = None,
52
+ ) -> ResponseEventStream:
53
+ return ResponseEventStream(response, on_event=on_event)
44
54
 
45
55
 
46
56
  def iter_sse_payloads(response: requests.Response) -> Iterator[dict[str, Any]]:
@@ -8,6 +8,26 @@ from typing import Any
8
8
  import requests
9
9
 
10
10
 
11
+ _MAX_ERROR_DETAIL_CHARS = 4_000
12
+
13
+
14
+ def raise_for_status_with_detail(response: requests.Response) -> None:
15
+ """Raise the usual HTTPError while retaining a bounded backend error body."""
16
+ try:
17
+ response.raise_for_status()
18
+ except requests.HTTPError as error:
19
+ detail = response.text.strip()
20
+ if not detail:
21
+ raise
22
+ if len(detail) > _MAX_ERROR_DETAIL_CHARS:
23
+ detail = detail[:_MAX_ERROR_DETAIL_CHARS] + "..."
24
+ raise requests.HTTPError(
25
+ f"{error}. Backend response: {detail}",
26
+ response=response,
27
+ request=error.request,
28
+ ) from error
29
+
30
+
11
31
  def request_with_retries(
12
32
  session: requests.Session,
13
33
  method: str,
@@ -26,7 +46,7 @@ def request_with_retries(
26
46
  if should_retry_response(response, attempt, max_retries=max_retries):
27
47
  sleep_before_retry(response, attempt, retry_base_delay=retry_base_delay)
28
48
  continue
29
- response.raise_for_status()
49
+ raise_for_status_with_detail(response)
30
50
  return response
31
51
  except (requests.Timeout, requests.ConnectionError) as exc:
32
52
  last_error = exc
@@ -251,7 +251,7 @@ def _event_error_message(event: ResponseStreamEvent) -> str:
251
251
  response = _event_response_dict(event)
252
252
  error = response.get("error") or getattr(event, "error", None) or {}
253
253
  if isinstance(error, dict):
254
- return error.get("message", "Response failed")
254
+ return error.get("message") or getattr(event, "message", None) or "Response failed"
255
255
  return str(error)
256
256
 
257
257
 
@@ -0,0 +1,45 @@
1
+ """Ephemeral sticky routing for one Responses turn, never shared across turns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from threading import Lock
7
+ from typing import Any, Optional
8
+
9
+ TURN_STATE_HEADER = "x-codex-turn-state"
10
+
11
+
12
+ def header_value(headers: Any, name: str, default=None) -> Any:
13
+ if not isinstance(headers, Mapping):
14
+ return default
15
+ return next((value for key, value in headers.items() if key.lower() == name), default)
16
+
17
+
18
+ class ResponsesTurnState:
19
+ def __init__(self) -> None:
20
+ self._lock = Lock()
21
+ self._identity = None
22
+ self._value = None
23
+ self._epoch = 0
24
+ self._closed = False
25
+
26
+ def prepare(self, identity: tuple) -> tuple[int, Optional[str]]:
27
+ with self._lock:
28
+ if self._closed:
29
+ raise RuntimeError("Responses turn is closed.")
30
+ if self._identity != identity:
31
+ self._identity, self._value = identity, None
32
+ self._epoch += 1
33
+ return self._epoch, self._value
34
+
35
+ def capture(self, epoch: int, value: Any) -> None:
36
+ if not isinstance(value, str) or not value or "\r" in value or "\n" in value:
37
+ return
38
+ with self._lock:
39
+ if not self._closed and self._epoch == epoch and self._value is None:
40
+ self._value = value
41
+
42
+ def close(self) -> None:
43
+ with self._lock:
44
+ self._closed = True
45
+ self._identity = self._value = None
@@ -288,7 +288,7 @@ class CodexRateLimitResetCredits:
288
288
 
289
289
  def list(self) -> RateLimitResetCredits:
290
290
  return RateLimitResetCredits.model_validate(
291
- self._client._get("/rate-limit-reset-credits")
291
+ self._client._get_wham("/wham/rate-limit-reset-credits")
292
292
  )
293
293
 
294
294
  def consume(
@@ -308,10 +308,9 @@ class CodexRateLimitResetCredits:
308
308
  if credit_id is not None:
309
309
  payload["credit_id"] = credit_id
310
310
  return ConsumeRateLimitResetCreditResponse.model_validate(
311
- self._client._post(
312
- "/rate-limit-reset-credits/consume",
313
- body=payload,
314
- ).json()
311
+ self._client._post_wham(
312
+ "/wham/rate-limit-reset-credits/consume", body=payload
313
+ )
315
314
  )
316
315
 
317
316
  def history(self, *, cursor: str | None = None) -> dict[str, Any]:
@@ -24,12 +24,6 @@ if TYPE_CHECKING:
24
24
  from .._client import CodexClient
25
25
 
26
26
 
27
- CODEX_REALTIME_V3_MODELS = frozenset({
28
- "gpt-live-1-codex",
29
- "gpt-live-1-boulder-alpha",
30
- })
31
-
32
-
33
27
  class Realtime:
34
28
  """Realtime resources matching the official OpenAI SDK surface where present."""
35
29
 
@@ -362,9 +356,8 @@ class RealtimeSideband:
362
356
  class RealtimeCalls:
363
357
  """Codex WebRTC call creation over ChatGPT OAuth.
364
358
 
365
- Realtime v3 uses the JSON call shape with one of the explicitly supported
366
- Codex ``gpt-live`` snapshots. Arbitrary ``gpt-live`` aliases are not accepted
367
- on this OAuth-authenticated route.
359
+ Realtime v3 uses the JSON call shape. Model identifiers are forwarded
360
+ unchanged; availability and validation belong to the backend.
368
361
  """
369
362
  def __init__(self, client: CodexClient) -> None:
370
363
  self._client = client
@@ -381,7 +374,7 @@ class RealtimeCalls:
381
374
  extra_body: Any = None,
382
375
  timeout: Any = _UNSET,
383
376
  ) -> RealtimeCallResponse:
384
- """Create a Codex Realtime v3 call using a confirmed Codex model.
377
+ """Create a Codex Realtime v3 call with a backend-validated model.
385
378
 
386
379
  The ChatGPT backend requires both ``session-id`` and ``thread-id`` on
387
380
  v3 call creation. Fresh UUIDs are generated when callers do not need
@@ -393,13 +386,6 @@ class RealtimeCalls:
393
386
  if not isinstance(session_payload, dict):
394
387
  raise TypeError("Expected `session` to serialize to a JSON object.")
395
388
  session_payload.pop("id", None)
396
- model = session_payload.get("model")
397
- if model not in CODEX_REALTIME_V3_MODELS:
398
- supported = ", ".join(sorted(CODEX_REALTIME_V3_MODELS))
399
- raise ValueError(
400
- "Codex Realtime v3 over ChatGPT OAuth requires "
401
- f"one of these `session.model` values: {supported}."
402
- )
403
389
  headers = {
404
390
  "session-id": session_id or str(uuid.uuid4()),
405
391
  "thread-id": thread_id or str(uuid.uuid4()),
@@ -5,7 +5,9 @@ from __future__ import annotations
5
5
  import json
6
6
  import uuid
7
7
  from collections.abc import Iterator
8
- from typing import Any, TYPE_CHECKING
8
+ from contextlib import contextmanager
9
+ from hashlib import sha256
10
+ from typing import Any, Optional, TYPE_CHECKING
9
11
 
10
12
  from .._models import CompactedResponse, ParsedResponse, Response, ResponseStreamEvent
11
13
  from .._streaming import stream_response_events
@@ -13,6 +15,7 @@ from .._utils import _UNSET, _default, _is_given, _jsonable, _reject_backend_uns
13
15
  from ._responses_payloads import (
14
16
  ResponsesCreateRequest,
15
17
  _usage_from_backend,
18
+ _event_error_message,
16
19
  collect_response,
17
20
  merge_text_format,
18
21
  normalize_input_item,
@@ -22,17 +25,47 @@ from ._responses_payloads import (
22
25
  pydantic_to_format,
23
26
  )
24
27
 
28
+ from ._responses_routing import ResponsesTurnState, TURN_STATE_HEADER, header_value
29
+
25
30
  if TYPE_CHECKING:
26
31
  from .._client import CodexClient
27
32
 
28
33
 
34
+ def _cache_affinity_headers(payload, extra_headers, default_headers=None):
35
+ headers = dict(extra_headers or {})
36
+ # Codex routes ChatGPT prompt caches using this header, not only the body key.
37
+ key = payload.get("prompt_cache_key")
38
+ if (
39
+ key
40
+ and not any(name.lower() == "session-id" for name in headers)
41
+ and not any(name.lower() == "session-id" for name in (default_headers or {}))
42
+ ):
43
+ headers["session-id"] = key
44
+ return headers or None
45
+
46
+
29
47
  class Responses:
30
- def __init__(self, client: CodexClient) -> None:
48
+ def __init__(self, client: CodexClient, *, _turn_state: Optional[ResponsesTurnState] = None) -> None:
31
49
  from .responses_websocket import ResponsesWebSocket
32
50
 
33
51
  self._client = client
52
+ self._turn_state = _turn_state
34
53
  self.websocket = ResponsesWebSocket(client)
35
54
 
55
+ @contextmanager
56
+ def turn(self) -> Iterator[Responses]:
57
+ """Yield isolated HTTP Responses routing for one logical agent turn.
58
+
59
+ Reuse this resource for generations/retries within that turn, not across
60
+ turns. No routing token is persisted or placed in the model's input.
61
+ Compaction is a separate backend turn and does not inherit this state.
62
+ """
63
+ state = ResponsesTurnState()
64
+ try:
65
+ yield Responses(self._client, _turn_state=state)
66
+ finally:
67
+ state.close()
68
+
36
69
  def create(
37
70
  self,
38
71
  *,
@@ -113,15 +146,50 @@ class Responses:
113
146
  payload = dict(request.payload)
114
147
  if extra_body:
115
148
  payload.update(_jsonable(extra_body))
149
+ defaults = self._client._session.headers
150
+ headers = _cache_affinity_headers(payload, extra_headers, defaults) or {}
151
+ state = self._turn_state
152
+ if state is not None:
153
+ def effective_header(name):
154
+ return header_value(headers, name, header_value(defaults, name))
155
+
156
+ # Also invalidate for explicit per-request authentication overrides. Keep
157
+ # only a fingerprint, never credentials, in this ephemeral routing state.
158
+ identity = (
159
+ self._client._auth_revision,
160
+ sha256(repr((
161
+ effective_header("authorization"), effective_header("chatgpt-account-id"),
162
+ )).encode()).digest(),
163
+ effective_header("session-id"),
164
+ payload.get("prompt_cache_key"),
165
+ )
166
+ epoch, value = state.prepare(identity)
167
+ explicit = effective_header(TURN_STATE_HEADER)
168
+ if explicit is not None:
169
+ state.capture(epoch, explicit)
170
+ elif value:
171
+ headers[TURN_STATE_HEADER] = value
172
+
116
173
  response = self._client._post(
117
174
  "/responses",
118
175
  body=payload,
119
176
  stream=True,
120
- headers=extra_headers,
177
+ headers=headers or None,
121
178
  params=extra_query,
122
179
  timeout=timeout,
123
180
  )
124
- events = stream_response_events(response)
181
+
182
+ def capture(headers):
183
+ if state is not None and self._client._auth_revision == identity[0]:
184
+ state.capture(epoch, header_value(headers, TURN_STATE_HEADER))
185
+
186
+ capture(getattr(response, "headers", {}))
187
+
188
+ def on_event(event):
189
+ if event.type == "response.metadata":
190
+ capture(getattr(event, "headers", None))
191
+
192
+ events = stream_response_events(response, on_event=on_event if state is not None else None)
125
193
  stream_enabled = bool(stream) if _is_given(stream) else False
126
194
 
127
195
  if stream_enabled:
@@ -315,7 +383,7 @@ class Responses:
315
383
  if not payload["input"] or payload["input"][-1].get("type") != "compaction_trigger":
316
384
  payload["input"].append({"type": "compaction_trigger"})
317
385
 
318
- headers = dict(extra_headers or {})
386
+ headers = _cache_affinity_headers(payload, extra_headers, self._client._session.headers) or {}
319
387
  beta_features = [
320
388
  value.strip()
321
389
  for value in headers.get("x-codex-beta-features", "").split(",")
@@ -337,20 +405,21 @@ class Responses:
337
405
  )
338
406
  output: list[Any] = []
339
407
  completed: dict[str, Any] = {}
340
- for event in stream_response_events(response):
341
- if event.type == "response.output_item.done":
342
- item = getattr(event, "item", None)
343
- if isinstance(item, dict):
344
- output.append(item)
345
- elif event.type == "response.completed":
346
- completed_value = getattr(event, "response", None) or {}
347
- completed = (
348
- completed_value
349
- if isinstance(completed_value, dict)
350
- else completed_value.model_dump()
351
- )
352
- elif event.type in {"response.failed", "error"}:
353
- raise RuntimeError("Remote compaction v2 failed")
408
+ with stream_response_events(response) as events:
409
+ for event in events:
410
+ if event.type == "response.output_item.done":
411
+ item = getattr(event, "item", None)
412
+ if isinstance(item, dict):
413
+ output.append(item)
414
+ elif event.type == "response.completed":
415
+ completed_value = getattr(event, "response", None) or {}
416
+ completed = (
417
+ completed_value
418
+ if isinstance(completed_value, dict)
419
+ else completed_value.model_dump()
420
+ )
421
+ elif event.type in {"response.failed", "error"}:
422
+ raise RuntimeError(f"Remote compaction v2 failed: {_event_error_message(event)}")
354
423
 
355
424
  compaction_items = [item for item in output if item.get("type") == "compaction"]
356
425
  if len(compaction_items) != 1:
@@ -110,6 +110,12 @@ 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
+ For HTTP `create`, `parse` and `compact` requests, a nonempty `prompt_cache_key`
114
+ also supplies the `session-id` header used by the ChatGPT backend for cache
115
+ affinity. An explicit `session-id` in `extra_headers` takes precedence
116
+ (case-insensitively). This preserves routing identity; it does not guarantee a
117
+ cache hit. WebSocket callers supply session headers at connection time.
118
+
113
119
  The OpenAI-compatible signature also accepts the following names so it can
114
120
  reject them clearly: `background`, `context_management`, `conversation`,
115
121
  `max_output_tokens`, `max_tool_calls`, `metadata`, `previous_response_id`,
@@ -344,12 +344,18 @@ with client.live.sideband.connect(session_id=live.session.id) as sideband:
344
344
 
345
345
  For Realtime v3, Codex sends `openai-alpha: quicksilver=v2`, the AVAS query
346
346
  parameters `intent=quicksilver&architecture=avas`, and a `gpt-live` session.
347
- Live WebRTC call-creation probes against the ChatGPT OAuth route validated both
347
+ Earlier WebRTC call-creation probes against the ChatGPT OAuth route accepted
348
348
  `gpt-live-1-codex` and `gpt-live-1-boulder-alpha`: each returned an SDP answer
349
- and a Realtime call id. The aliases `gpt-live`, `gpt-live-1`,
350
- `gpt-live-latest`, and an unknown suffix returned HTTP 400. `create_v3`
351
- therefore accepts exactly the two confirmed identifiers rather than every
352
- `gpt-live` prefix.
349
+ and a Realtime call id. Some other identifiers returned HTTP 400 at that time.
350
+ The SDK does not encode these observations as an allowlist: `create_v3` forwards
351
+ model identifiers unchanged and preserves backend errors. Availability depends
352
+ on the account and backend rollout.
353
+
354
+ On October 22, 2026, a `gpt-live-1.1` probe returned an SDP answer and opened the
355
+ WebRTC data channel using a synthetic silent audio track (no microphone).
356
+ The server subsequently emitted `forbidden: Voice session access denied.`
357
+ No model reply was received; successful call creation is not proof of voice
358
+ session authorization.
353
359
 
354
360
  This is distinct from the public Realtime API documented for developer API keys.
355
361
  On the OpenAI API host, the same Codex implementation uses `/v1/live` for the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-backend-sdk"
7
- version = "0.5.3"
7
+ version = "0.5.5"
8
8
  description = "Unofficial Python SDK for the ChatGPT Codex backend API"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -73,8 +73,10 @@ def test_retry_does_not_retry_client_errors(monkeypatch):
73
73
 
74
74
  try:
75
75
  client._get_raw("/models")
76
- except requests.HTTPError:
77
- pass
76
+ except requests.HTTPError as error:
77
+ assert 'Backend response: {"error": "bad request"}' in str(error)
78
+ assert error.response is not None
79
+ assert error.response.status_code == 400
78
80
  else:
79
81
  raise AssertionError("Expected HTTPError")
80
82
 
@@ -82,6 +84,21 @@ def test_retry_does_not_retry_client_errors(monkeypatch):
82
84
  assert sleeps == []
83
85
 
84
86
 
87
+ def test_http_error_detail_is_bounded():
88
+ body = b"x" * 5_000
89
+ client = RetryClient([_response(400, body=body)])
90
+
91
+ try:
92
+ client._get_raw("/models")
93
+ except requests.HTTPError as error:
94
+ message = str(error)
95
+ assert "Backend response: " in message
96
+ assert message.endswith("...")
97
+ assert len(message) < 4_200
98
+ else:
99
+ raise AssertionError("Expected HTTPError")
100
+
101
+
85
102
  def test_retry_retries_transport_timeout(monkeypatch):
86
103
  sleeps = []
87
104
  monkeypatch.setattr(transport_module.time, "sleep", sleeps.append)