codex-backend-sdk 0.3.5__tar.gz → 0.3.7__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 (42) hide show
  1. codex_backend_sdk-0.3.7/CHANGELOG.md +46 -0
  2. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/PKG-INFO +10 -9
  3. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/README.md +9 -8
  4. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/__init__.py +2 -3
  5. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/_client.py +17 -20
  6. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/_models.py +27 -1
  7. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/oauth.py +22 -52
  8. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/resources/realtime.py +36 -6
  9. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/storage.py +4 -4
  10. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/docs/backend-api.md +17 -9
  11. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/pyproject.toml +1 -1
  12. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_account_info.py +19 -0
  13. codex_backend_sdk-0.3.7/tests/test_realtime_resource.py +130 -0
  14. codex_backend_sdk-0.3.7/uv.lock +792 -0
  15. codex_backend_sdk-0.3.5/CHANGELOG.md +0 -21
  16. codex_backend_sdk-0.3.5/tests/test_realtime_resource.py +0 -80
  17. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/.gitignore +0 -0
  18. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/LICENSE +0 -0
  19. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/_streaming.py +0 -0
  20. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/_transport.py +0 -0
  21. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/_utils.py +0 -0
  22. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/codex_client.py +0 -0
  23. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/pkce.py +0 -0
  24. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/resources/__init__.py +0 -0
  25. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/resources/_responses_payloads.py +0 -0
  26. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/resources/codex.py +0 -0
  27. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/resources/files.py +0 -0
  28. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/resources/models.py +0 -0
  29. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  30. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/codex_backend_sdk/resources/responses.py +0 -0
  31. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/examples/agent.py +0 -0
  32. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/conftest.py +0 -0
  33. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_basic.py +0 -0
  34. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_client_retry.py +0 -0
  35. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_codex_resources.py +0 -0
  36. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_conversation.py +0 -0
  37. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_files_resource.py +0 -0
  38. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_openai_oauth_resources.py +0 -0
  39. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_reasoning.py +0 -0
  40. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_responses_resource.py +0 -0
  41. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_structured_output.py +0 -0
  42. {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.7}/tests/test_tools.py +0 -0
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.3.7] - 2026-07-17
6
+
7
+ ### Added
8
+ - Added typed Realtime call results through `RealtimeCallResponse.answer_sdp` and `RealtimeCallResponse.call_id`.
9
+ - Added Codex AVAS session payload support, including automatic removal of the server-generated session `id` and the required `quicksilver` query parameters.
10
+
11
+ ### Documentation
12
+ - Documented that the ChatGPT-authenticated Codex WebRTC route is experimental and rollout-dependent, while the public Realtime WebSocket route still requires a developer API key.
13
+
14
+ ### Tests
15
+ - Added coverage for SDP response parsing, call ID validation, AVAS payload construction, and invalid session payloads.
16
+
17
+ ## [0.3.6] - 2026-07-11
18
+
19
+ ### Added
20
+ - Added Voice v2 WebSocket headers through `client.realtime.websocket_headers(...)`, using the API key stored by OAuth when available or `OPENAI_API_KEY` as a fallback.
21
+ - Added `authenticate(force=True)` for explicit interactive reauthentication.
22
+
23
+ ### Changed
24
+ - ChatGPT OAuth now attempts the same optional ID-token-to-API-key exchange as Codex CLI and persists the result in the official `OPENAI_API_KEY` auth field.
25
+ - Removed the legacy `request_api_key` authentication option; API-key acquisition is now an internal OAuth concern and never prevents regular Codex login.
26
+
27
+ ### Tests
28
+ - Added coverage for forced authentication and Realtime credential selection/error behavior.
29
+
30
+ ## [0.3.5] - 2026-05-18
31
+
32
+ ### Fixed
33
+ - Preserved `usage` on `client.responses.compact(...)` results so `CompactedResponse` exposes backend token accounting.
34
+
35
+ ### Tests
36
+ - Added coverage to verify compact responses retain `input_tokens`, `output_tokens`, and `total_tokens`.
37
+
38
+ ## [0.3.4] - 2026-05-15
39
+
40
+ ### Added
41
+ - Added `authenticate(interactive=False)` to allow non-interactive credential checks without triggering the browser OAuth flow.
42
+ - Added `client.authenticated` for simple auth-state introspection.
43
+ - Added `client.account_info()` to expose safe non-secret account metadata (`authenticated`, `account_id`, `email`, `plan_type`).
44
+
45
+ ### Documentation
46
+ - Documented the non-interactive authentication helpers in `README.md`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-backend-sdk
3
- Version: 0.3.5
3
+ Version: 0.3.7
4
4
  Summary: Unofficial Python SDK for the ChatGPT Codex backend API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -186,8 +186,8 @@ resources (`responses`, `models`, `realtime`) or Codex-only resources (`codex`).
186
186
  | `POST /backend-api/codex/responses/compact` | `client.responses.compact(...)` | Codex-specific helper for encrypted context compaction. |
187
187
  | `POST /backend-api/codex/memories/trace_summarize` | `client.codex.memories.trace_summarize(...)` | Raw Codex memory trace summarization helper. |
188
188
  | `GET /backend-api/codex/models` | `client.models.list()` / `client.models.retrieve(...)` | OpenAI-shaped model objects with Codex metadata preserved as extra fields. |
189
- | `POST /backend-api/codex/realtime/calls` | `client.realtime.calls.create(...)` | OpenAI-shaped SDP call creation for realtime sessions. |
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. |
189
+ | `POST /backend-api/codex/realtime/calls` | `client.realtime.calls.create(...)` | Experimental SDP call creation. The protocol is implemented by Codex, but ChatGPT routing is rollout-dependent and may return `404 Not Found`. |
190
+ | `wss://api.openai.com/v1/realtime?model=...` | `client.realtime_websocket_url(...)` / `client.realtime.websocket_headers(...)` | Voice v2 helpers; requires a Realtime API key obtained during OAuth or supplied by the auth store. |
191
191
  | `POST /v1/embeddings` | `client.embeddings.create(...)` | Uses the Codex OAuth access token against `api.openai.com`; verified with `text-embedding-3-small`. |
192
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`. |
193
193
  | `GET /backend-api/wham/usage` | `client.codex.usage()` | Codex/ChatGPT quota and rate-limit status. |
@@ -343,17 +343,18 @@ answer = client.realtime.calls.create(
343
343
  print(answer.text)
344
344
  ```
345
345
 
346
- For WebSocket-based plugins such as `codex-agent`, the client also exposes small
347
- helpers that reuse the OpenAI API key stored by the Codex OAuth flow:
346
+ For WebSocket-based plugins such as `codex-agent`, the client also exposes the
347
+ Voice v2 connection details:
348
348
 
349
349
  ```python
350
350
  url = client.realtime_websocket_url(model="gpt-realtime-1.5")
351
- headers = client.realtime_websocket_headers(session_id="voice-session")
351
+ headers = client.realtime.websocket_headers(session_id="voice-session")
352
352
  ```
353
353
 
354
- `realtime_websocket_headers(...)` requires `~/.codex/auth.json` to contain
355
- `openai_api_key`. The default `authenticate(request_api_key=True)` flow stores
356
- that key when available.
354
+ During interactive ChatGPT OAuth login, the SDK exchanges the fresh ID token for
355
+ the temporary API key required by Realtime and stores it with the other local
356
+ credentials. Existing credentials created by older SDK versions may require one
357
+ forced interactive login before these headers are available.
357
358
 
358
359
  For non-interactive checks, you can avoid triggering a browser login flow:
359
360
 
@@ -160,8 +160,8 @@ resources (`responses`, `models`, `realtime`) or Codex-only resources (`codex`).
160
160
  | `POST /backend-api/codex/responses/compact` | `client.responses.compact(...)` | Codex-specific helper for encrypted context compaction. |
161
161
  | `POST /backend-api/codex/memories/trace_summarize` | `client.codex.memories.trace_summarize(...)` | Raw Codex memory trace summarization helper. |
162
162
  | `GET /backend-api/codex/models` | `client.models.list()` / `client.models.retrieve(...)` | OpenAI-shaped model objects with Codex metadata preserved as extra fields. |
163
- | `POST /backend-api/codex/realtime/calls` | `client.realtime.calls.create(...)` | OpenAI-shaped SDP call creation for realtime sessions. |
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. |
163
+ | `POST /backend-api/codex/realtime/calls` | `client.realtime.calls.create(...)` | Experimental SDP call creation. The protocol is implemented by Codex, but ChatGPT routing is rollout-dependent and may return `404 Not Found`. |
164
+ | `wss://api.openai.com/v1/realtime?model=...` | `client.realtime_websocket_url(...)` / `client.realtime.websocket_headers(...)` | Voice v2 helpers; requires a Realtime API key obtained during OAuth or supplied by the auth store. |
165
165
  | `POST /v1/embeddings` | `client.embeddings.create(...)` | Uses the Codex OAuth access token against `api.openai.com`; verified with `text-embedding-3-small`. |
166
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`. |
167
167
  | `GET /backend-api/wham/usage` | `client.codex.usage()` | Codex/ChatGPT quota and rate-limit status. |
@@ -317,17 +317,18 @@ answer = client.realtime.calls.create(
317
317
  print(answer.text)
318
318
  ```
319
319
 
320
- For WebSocket-based plugins such as `codex-agent`, the client also exposes small
321
- helpers that reuse the OpenAI API key stored by the Codex OAuth flow:
320
+ For WebSocket-based plugins such as `codex-agent`, the client also exposes the
321
+ Voice v2 connection details:
322
322
 
323
323
  ```python
324
324
  url = client.realtime_websocket_url(model="gpt-realtime-1.5")
325
- headers = client.realtime_websocket_headers(session_id="voice-session")
325
+ headers = client.realtime.websocket_headers(session_id="voice-session")
326
326
  ```
327
327
 
328
- `realtime_websocket_headers(...)` requires `~/.codex/auth.json` to contain
329
- `openai_api_key`. The default `authenticate(request_api_key=True)` flow stores
330
- that key when available.
328
+ During interactive ChatGPT OAuth login, the SDK exchanges the fresh ID token for
329
+ the temporary API key required by Realtime and stores it with the other local
330
+ credentials. Existing credentials created by older SDK versions may require one
331
+ forced interactive login before these headers are available.
331
332
 
332
333
  For non-interactive checks, you can avoid triggering a browser login flow:
333
334
 
@@ -17,9 +17,9 @@ Quickstart:
17
17
  print(response.output_text)
18
18
  """
19
19
 
20
- __version__ = "0.3.5"
20
+ __version__ = "0.3.7"
21
21
 
22
- from .oauth import run_oauth_flow, refresh_access_token, obtain_api_key
22
+ from .oauth import run_oauth_flow, refresh_access_token
23
23
  from .storage import load_tokens, save_tokens, TokenStore
24
24
  from .codex_client import (
25
25
  CodexBackendUnsupportedParameterError,
@@ -83,7 +83,6 @@ __all__ = [
83
83
  "image_b64",
84
84
  "run_oauth_flow",
85
85
  "refresh_access_token",
86
- "obtain_api_key",
87
86
  "load_tokens",
88
87
  "save_tokens",
89
88
  "TokenStore",
@@ -65,28 +65,38 @@ class CodexClient:
65
65
  def authenticate(
66
66
  self,
67
67
  *,
68
- request_api_key: bool = True,
69
68
  interactive: bool = True,
69
+ force: bool = False,
70
70
  ) -> "CodexClient":
71
- from .oauth import refresh_access_token, run_oauth_flow
71
+ from .oauth import obtain_api_key, refresh_access_token, run_oauth_flow
72
72
 
73
73
  def refresh(store: TokenStore) -> Optional[TokenStore]:
74
74
  try:
75
75
  data = refresh_access_token(store.refresh_token)
76
+ id_token = data.get("id_token", store.id_token_raw)
77
+ api_key = store.openai_api_key
78
+ if not api_key and data.get("id_token"):
79
+ try:
80
+ api_key = obtain_api_key(id_token)
81
+ except Exception:
82
+ pass
76
83
  refreshed = TokenStore.from_exchange(
77
84
  access_token=data.get("access_token", store.access_token),
78
85
  refresh_token=data.get("refresh_token", store.refresh_token),
79
- id_token=data.get("id_token", store.id_token_raw),
80
- api_key=store.openai_api_key,
86
+ id_token=id_token,
87
+ openai_api_key=api_key,
81
88
  )
82
89
  save_tokens(refreshed)
83
90
  return refreshed
84
91
  except Exception:
85
92
  return None
86
93
 
87
- store = load_tokens()
94
+ if force and not interactive:
95
+ raise ValueError("Forced authentication requires interactive=True.")
96
+
97
+ store = None if force else load_tokens()
88
98
  if store is not None:
89
- if token_needs_refresh(store) and store.refresh_token:
99
+ if (token_needs_refresh(store) or not store.openai_api_key) and store.refresh_token:
90
100
  store = refresh(store) or store
91
101
  if not token_needs_refresh(store) or self._probe_auth(store):
92
102
  self._set_store(store)
@@ -95,7 +105,7 @@ class CodexClient:
95
105
  if not interactive:
96
106
  raise RuntimeError("No usable stored Codex credentials; interactive login required.")
97
107
 
98
- self._set_store(run_oauth_flow(request_api_key=request_api_key))
108
+ self._set_store(run_oauth_flow())
99
109
  return self
100
110
 
101
111
  @property
@@ -312,18 +322,5 @@ class CodexClient:
312
322
  raise ValueError(f"Expected a non-empty value for `model` but received {model!r}")
313
323
  return "wss://api.openai.com/v1/realtime?" + urllib.parse.urlencode({"model": model})
314
324
 
315
- def realtime_websocket_headers(self, *, session_id: Optional[str] = None) -> dict[str, str]:
316
- """Return headers for an OpenAI Realtime WebSocket connection."""
317
- self._ensure_auth()
318
- if self._store is None or not self._store.openai_api_key:
319
- raise RuntimeError(
320
- "Realtime WebSocket requires an OpenAI API key. "
321
- "Call authenticate(request_api_key=True) to persist one."
322
- )
323
- return {
324
- "Authorization": f"Bearer {self._store.openai_api_key}",
325
- "OpenAI-Beta": "realtime=v1",
326
- }
327
-
328
325
 
329
326
  OpenAI = CodexClient
@@ -304,4 +304,30 @@ class BinaryResponseContent:
304
304
 
305
305
 
306
306
  class RealtimeCallResponse(BinaryResponseContent):
307
- """Binary SDP response returned by ``client.realtime.calls.create``."""
307
+ """SDP answer and call identifier returned by Realtime call creation."""
308
+
309
+ @property
310
+ def answer_sdp(self) -> str:
311
+ return self.text
312
+
313
+ @property
314
+ def call_id(self) -> str:
315
+ location = self.response.headers.get("Location")
316
+ if not location:
317
+ raise RuntimeError("Realtime call response is missing the Location header.")
318
+ path = location.split("?", 1)[0].rstrip("/")
319
+ call_id = path.rsplit("/", 1)[-1]
320
+ if call_id.startswith("rtc_") or _is_uuid(call_id):
321
+ return call_id
322
+ raise RuntimeError(
323
+ f"Realtime call Location does not contain a valid call id: {location}"
324
+ )
325
+
326
+
327
+ def _is_uuid(value: str) -> bool:
328
+ if len(value) != 36:
329
+ return False
330
+ return all(
331
+ char == "-" if index in {8, 13, 18, 23} else char in "0123456789abcdefABCDEF"
332
+ for index, char in enumerate(value)
333
+ )
@@ -150,48 +150,22 @@ def _exchange_code(code: str, pkce: PkceCodes) -> dict:
150
150
  return resp.json()
151
151
 
152
152
 
153
- def obtain_api_key(id_token: str, access_token: Optional[str] = None) -> Optional[str]:
154
- """
155
- Exchange an id_token (or access_token fallback) for an OpenAI API key.
156
- Returns the API key string, or None on failure.
157
-
158
- Some accounts (personal API Platform orgs) don't embed organization_id in
159
- the id_token. We first try with the id_token; if that fails with an org
160
- error, we retry using the access_token (already scoped to api.openai.com/v1).
161
- """
162
- def _exchange(subject: str, token_type: str) -> Optional[str]:
163
- resp = requests.post(
164
- f"{ISSUER}/oauth/token",
165
- headers={"Content-Type": "application/x-www-form-urlencoded"},
166
- data={
167
- "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
168
- "client_id": CLIENT_ID,
169
- "requested_token": "openai-api-key",
170
- "subject_token": subject,
171
- "subject_token_type": token_type,
172
- },
173
- timeout=30,
174
- )
175
- if resp.ok:
176
- return resp.json().get("access_token")
177
- short_type = token_type.split(":")[-1]
178
- print(f"[auth] {short_type} exchange → {resp.status_code}: {resp.text}")
179
- return None
180
-
181
- try:
182
- key = _exchange(id_token, "urn:ietf:params:oauth:token-type:id_token")
183
- if key:
184
- return key
185
-
186
- if access_token:
187
- print("[auth] retrying with access_token…")
188
- key = _exchange(access_token, "urn:ietf:params:oauth:token-type:access_token")
189
- if key:
190
- return key
191
-
192
- except Exception as exc:
193
- print(f"[auth] API key exchange error: {exc}")
194
- return None
153
+ def obtain_api_key(id_token: str) -> str:
154
+ """Exchange a fresh ChatGPT ID token for the API key used by Realtime."""
155
+ resp = requests.post(
156
+ f"{ISSUER}/oauth/token",
157
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
158
+ data={
159
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
160
+ "client_id": CLIENT_ID,
161
+ "requested_token": "openai-api-key",
162
+ "subject_token": id_token,
163
+ "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
164
+ },
165
+ timeout=30,
166
+ )
167
+ resp.raise_for_status()
168
+ return resp.json()["access_token"]
195
169
 
196
170
 
197
171
  def refresh_access_token(refresh_token: str) -> dict:
@@ -217,7 +191,6 @@ def refresh_access_token(refresh_token: str) -> dict:
217
191
  def run_oauth_flow(
218
192
  *,
219
193
  open_browser: bool = True,
220
- request_api_key: bool = True,
221
194
  persist: bool = True,
222
195
  workspace_id: Optional[str] = None,
223
196
  scopes: Optional[str] = None,
@@ -226,8 +199,7 @@ def run_oauth_flow(
226
199
  Run the full OAuth authorization-code + PKCE flow.
227
200
 
228
201
  Opens the browser (unless open_browser=False), waits for the callback,
229
- exchanges the code for tokens, optionally fetches an API key,
230
- and saves everything to ~/.codex/auth.json.
202
+ exchanges the code for OAuth tokens and saves them to ~/.codex/auth.json.
231
203
 
232
204
  Returns a TokenStore with all credentials.
233
205
  """
@@ -278,18 +250,16 @@ def run_oauth_flow(
278
250
  refresh_token = raw["refresh_token"]
279
251
  id_token = raw["id_token"]
280
252
 
281
- api_key: Optional[str] = None
282
- if request_api_key:
283
- print("[auth] Exchanging for OpenAI API key …")
284
- api_key = obtain_api_key(id_token, access_token)
285
- if api_key:
286
- print(f"[auth] API key obtained: {api_key[:8]}…")
253
+ try:
254
+ openai_api_key = obtain_api_key(id_token)
255
+ except requests.HTTPError:
256
+ openai_api_key = None
287
257
 
288
258
  store = TokenStore.from_exchange(
289
259
  access_token=access_token,
290
260
  refresh_token=refresh_token,
291
261
  id_token=id_token,
292
- api_key=api_key,
262
+ openai_api_key=openai_api_key,
293
263
  )
294
264
 
295
265
  if persist:
@@ -1,6 +1,8 @@
1
1
  """Realtime resources."""
2
2
 
3
3
  from __future__ import annotations
4
+
5
+ import os
4
6
  from typing import Any, Optional, TYPE_CHECKING
5
7
 
6
8
  from .._models import RealtimeCallResponse
@@ -17,8 +19,28 @@ class Realtime:
17
19
  self._client = client
18
20
  self.calls = RealtimeCalls(client)
19
21
 
22
+ def websocket_headers(self, *, session_id: Optional[str] = None) -> dict[str, str]:
23
+ """Return Voice v2 headers backed by an available Realtime API key."""
24
+ store = self._client._store
25
+ api_key = (store.openai_api_key if store else None) or os.environ.get("OPENAI_API_KEY")
26
+ if not api_key:
27
+ raise RuntimeError("Realtime Voice v2 requires an OpenAI API key.")
28
+ headers = {
29
+ "Authorization": f"Bearer {api_key}",
30
+ "originator": "codex_cli_rs",
31
+ }
32
+ if session_id:
33
+ headers["x-session-id"] = session_id
34
+ return headers
35
+
20
36
 
21
37
  class RealtimeCalls:
38
+ """Experimental Codex WebRTC call creation.
39
+
40
+ Availability depends on the Realtime call route enabled for the authenticated
41
+ ChatGPT account. A correctly authenticated request may still return 404 while
42
+ the backend is not rolled out.
43
+ """
22
44
  def __init__(self, client: CodexClient) -> None:
23
45
  self._client = client
24
46
 
@@ -49,15 +71,23 @@ class RealtimeCalls:
49
71
  )
50
72
  return RealtimeCallResponse(response)
51
73
 
74
+ session_payload = _jsonable(session)
75
+ if not isinstance(session_payload, dict):
76
+ raise TypeError("Expected `session` to serialize to a JSON object.")
77
+ session_payload.pop("id", None)
78
+ body = {
79
+ "sdp": sdp,
80
+ "session": session_payload,
81
+ **(_jsonable(extra_body) if extra_body else {}),
82
+ }
83
+ query = {"intent": "quicksilver", "architecture": "avas"}
84
+ if extra_query:
85
+ query.update(extra_query)
52
86
  response = self._client._post_raw(
53
87
  "/realtime/calls",
54
- body={
55
- "sdp": sdp,
56
- "session": _jsonable(session),
57
- **(_jsonable(extra_body) if extra_body else {}),
58
- },
88
+ body=body,
59
89
  headers={"Accept": "application/sdp", **(extra_headers or {})},
60
- params=extra_query,
90
+ params=query,
61
91
  timeout=timeout,
62
92
  )
63
93
  return RealtimeCallResponse(response)
@@ -66,7 +66,7 @@ class TokenStore:
66
66
  access_token: str,
67
67
  refresh_token: str,
68
68
  id_token: str,
69
- api_key: Optional[str] = None,
69
+ openai_api_key: Optional[str] = None,
70
70
  ) -> "TokenStore":
71
71
  claims = _extract_openai_claims(id_token)
72
72
  access_claims = _extract_openai_claims(access_token)
@@ -76,7 +76,7 @@ class TokenStore:
76
76
  refresh_token=refresh_token,
77
77
  id_token_raw=id_token,
78
78
  account_id=claims.get("chatgpt_account_id"),
79
- openai_api_key=api_key,
79
+ openai_api_key=openai_api_key,
80
80
  last_refresh=datetime.now(timezone.utc).isoformat(),
81
81
  email=_decode_jwt_payload(id_token).get("email"),
82
82
  plan_type=access_claims.get("chatgpt_plan_type"),
@@ -119,7 +119,7 @@ def save_tokens(store: TokenStore) -> None:
119
119
 
120
120
  payload = {
121
121
  "auth_mode": "chatgpt",
122
- "openai_api_key": store.openai_api_key,
122
+ "OPENAI_API_KEY": store.openai_api_key,
123
123
  "tokens": {
124
124
  "access_token": store.access_token,
125
125
  "refresh_token": store.refresh_token,
@@ -159,7 +159,7 @@ def load_tokens() -> Optional[TokenStore]:
159
159
  refresh_token=refresh_token,
160
160
  id_token_raw=id_token,
161
161
  account_id=tokens.get("account_id"),
162
- openai_api_key=data.get("openai_api_key"),
162
+ openai_api_key=data.get("OPENAI_API_KEY") or data.get("openai_api_key"),
163
163
  last_refresh=data.get("last_refresh"),
164
164
  email=_decode_jwt_payload(id_token).get("email"),
165
165
  plan_type=_extract_openai_claims(access_token).get("chatgpt_plan_type"),
@@ -264,14 +264,21 @@ Realtime audio/video call initiation.
264
264
 
265
265
  **SDK method**: `client.realtime.calls.create(...)`
266
266
 
267
- **Status**: Supported. The SDK follows the official OpenAI SDK shape:
267
+ **Status**: Experimental and rollout-dependent. The SDK follows the Codex
268
+ client protocol:
268
269
 
269
270
  - plain SDP offer: `client.realtime.calls.create(sdp=offer_sdp)`
270
- - SDP offer plus session payload:
271
+ - AVAS SDP offer plus session payload:
271
272
  `client.realtime.calls.create(sdp=offer_sdp, session={...})`
272
273
 
273
- The response is returned as binary SDP content with `.content`, `.text`,
274
- `.read()`, `.iter_bytes()`, and `.write_to_file(...)` helpers.
274
+ The OAuth-authenticated ChatGPT route is not enabled for every account and may
275
+ return `404 Not Found` until Codex supplies an experimental WebRTC call base URL.
276
+ This is distinct from the public Realtime WebSocket route, which currently
277
+ requires a developer API key.
278
+
279
+ The response exposes `.answer_sdp` and `.call_id`, while preserving the binary
280
+ helpers `.content`, `.text`, `.read()`, `.iter_bytes()`, and
281
+ `.write_to_file(...)`.
275
282
 
276
283
  ---
277
284
 
@@ -283,7 +290,7 @@ sharing the Codex OAuth/token store.
283
290
  **SDK methods**:
284
291
 
285
292
  - `client.realtime_websocket_url(model="gpt-realtime-1.5")`
286
- - `client.realtime_websocket_headers(session_id="...")`
293
+ - `client.realtime.websocket_headers(session_id="...")`
287
294
 
288
295
  The URL helper returns:
289
296
 
@@ -291,10 +298,11 @@ The URL helper returns:
291
298
  wss://api.openai.com/v1/realtime?model=...
292
299
  ```
293
300
 
294
- The headers helper returns `Authorization: Bearer <openai_api_key>` and
295
- `OpenAI-Beta: realtime=v1`. It requires the Codex auth store to contain
296
- `openai_api_key`; the default `authenticate(request_api_key=True)` flow requests
297
- and persists it.
301
+ The headers helper returns `Authorization: Bearer <openai_api_key>`, the Codex
302
+ originator, and the optional session id. Interactive ChatGPT OAuth attempts to
303
+ exchange its fresh ID token for this Realtime API key and persists it when the
304
+ account is entitled to one. If that exchange is unavailable, regular Codex OAuth
305
+ continues to work but Voice v2 requires a separately provisioned API key.
298
306
 
299
307
  ---
300
308
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-backend-sdk"
7
- version = "0.3.5"
7
+ version = "0.3.7"
8
8
  description = "Unofficial Python SDK for the ChatGPT Codex backend API"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -66,3 +66,22 @@ def test_authenticate_non_interactive_without_tokens_raises(monkeypatch):
66
66
  assert "interactive login required" in str(exc)
67
67
  else:
68
68
  raise AssertionError("Expected RuntimeError")
69
+
70
+
71
+ def test_authenticate_force_runs_interactive_flow(monkeypatch):
72
+ expected = _store()
73
+ monkeypatch.setattr("codex_backend_sdk._client.load_tokens", lambda: _store())
74
+ monkeypatch.setattr("codex_backend_sdk.oauth.run_oauth_flow", lambda: expected)
75
+
76
+ client = CodexClient().authenticate(force=True)
77
+
78
+ assert client._store is expected
79
+
80
+
81
+ def test_authenticate_force_requires_interactive_mode():
82
+ try:
83
+ CodexClient().authenticate(force=True, interactive=False)
84
+ except ValueError as exc:
85
+ assert "interactive=True" in str(exc)
86
+ else:
87
+ raise AssertionError("Expected ValueError")