codex-backend-sdk 0.3.4__tar.gz → 0.3.6__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 (40) hide show
  1. codex_backend_sdk-0.3.6/CHANGELOG.md +34 -0
  2. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/PKG-INFO +9 -8
  3. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/README.md +8 -7
  4. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/__init__.py +2 -3
  5. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_client.py +17 -20
  6. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_models.py +1 -0
  7. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/oauth.py +22 -52
  8. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/_responses_payloads.py +1 -7
  9. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/realtime.py +16 -0
  10. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/responses.py +7 -1
  11. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/storage.py +4 -4
  12. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/docs/backend-api.md +6 -5
  13. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/pyproject.toml +1 -1
  14. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_account_info.py +19 -0
  15. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_realtime_resource.py +30 -7
  16. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_responses_resource.py +6 -1
  17. codex_backend_sdk-0.3.4/CHANGELOG.md +0 -13
  18. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/.gitignore +0 -0
  19. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/LICENSE +0 -0
  20. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_streaming.py +0 -0
  21. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_transport.py +0 -0
  22. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_utils.py +0 -0
  23. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/codex_client.py +0 -0
  24. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/pkce.py +0 -0
  25. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/__init__.py +0 -0
  26. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/codex.py +0 -0
  27. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/files.py +0 -0
  28. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/models.py +0 -0
  29. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  30. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/examples/agent.py +0 -0
  31. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/conftest.py +0 -0
  32. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_basic.py +0 -0
  33. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_client_retry.py +0 -0
  34. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_codex_resources.py +0 -0
  35. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_conversation.py +0 -0
  36. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_files_resource.py +0 -0
  37. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_openai_oauth_resources.py +0 -0
  38. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_reasoning.py +0 -0
  39. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_structured_output.py +0 -0
  40. {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_tools.py +0 -0
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.3.6] - 2026-07-11
6
+
7
+ ### Added
8
+ - 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.
9
+ - Added `authenticate(force=True)` for explicit interactive reauthentication.
10
+
11
+ ### Changed
12
+ - 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.
13
+ - Removed the legacy `request_api_key` authentication option; API-key acquisition is now an internal OAuth concern and never prevents regular Codex login.
14
+
15
+ ### Tests
16
+ - Added coverage for forced authentication and Realtime credential selection/error behavior.
17
+
18
+ ## [0.3.5] - 2026-05-18
19
+
20
+ ### Fixed
21
+ - Preserved `usage` on `client.responses.compact(...)` results so `CompactedResponse` exposes backend token accounting.
22
+
23
+ ### Tests
24
+ - Added coverage to verify compact responses retain `input_tokens`, `output_tokens`, and `total_tokens`.
25
+
26
+ ## [0.3.4] - 2026-05-15
27
+
28
+ ### Added
29
+ - Added `authenticate(interactive=False)` to allow non-interactive credential checks without triggering the browser OAuth flow.
30
+ - Added `client.authenticated` for simple auth-state introspection.
31
+ - Added `client.account_info()` to expose safe non-secret account metadata (`authenticated`, `account_id`, `email`, `plan_type`).
32
+
33
+ ### Documentation
34
+ - 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.4
3
+ Version: 0.3.6
4
4
  Summary: Unofficial Python SDK for the ChatGPT Codex backend API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -187,7 +187,7 @@ resources (`responses`, `models`, `realtime`) or Codex-only resources (`codex`).
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
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. |
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
 
@@ -161,7 +161,7 @@ resources (`responses`, `models`, `realtime`) or Codex-only resources (`codex`).
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
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. |
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.4"
20
+ __version__ = "0.3.6"
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
@@ -258,6 +258,7 @@ class CompactedResponse(CodexBaseModel):
258
258
  id: str
259
259
  object: str = "response.compacted"
260
260
  output: list[dict[str, Any]] = Field(default_factory=list)
261
+ usage: Optional[ResponseUsage] = Field(default_factory=ResponseUsage)
261
262
 
262
263
 
263
264
  class BinaryResponseContent:
@@ -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:
@@ -175,13 +175,7 @@ def normalize_input_item(item: Any) -> dict[str, Any]:
175
175
  def normalize_tools(tools: Any) -> list[dict[str, Any]]:
176
176
  if not _is_given(tools) or tools is None:
177
177
  return []
178
- normalized = []
179
- for tool in tools:
180
- item = _as_dict(tool)
181
- if item.get("type") == "web_search":
182
- item["type"] = "web_search_preview"
183
- normalized.append(item)
184
- return normalized
178
+ return [_as_dict(tool) for tool in tools]
185
179
 
186
180
 
187
181
  def normalize_reasoning(reasoning: Any) -> dict[str, Any]:
@@ -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,6 +19,20 @@ 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:
22
38
  def __init__(self, client: CodexClient) -> None:
@@ -10,6 +10,7 @@ from .._streaming import stream_response_events
10
10
  from .._utils import _UNSET, _default, _is_given, _reject_backend_unsupported
11
11
  from ._responses_payloads import (
12
12
  ResponsesCreateRequest,
13
+ _usage_from_backend,
13
14
  collect_response,
14
15
  merge_text_format,
15
16
  normalize_input_item,
@@ -221,4 +222,9 @@ class Responses:
221
222
  if _is_given(text) and text is not None:
222
223
  payload["text"] = normalize_text(text)
223
224
  data = self._client._post("/responses/compact", body=payload).json()
224
- return CompactedResponse(id=data.get("id", ""), output=data.get("output", []))
225
+ return CompactedResponse(
226
+ id=data.get("id", ""),
227
+ object=data.get("object", "response.compacted"),
228
+ output=data.get("output", []),
229
+ usage=_usage_from_backend(data.get("usage")),
230
+ )
@@ -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"),
@@ -283,7 +283,7 @@ sharing the Codex OAuth/token store.
283
283
  **SDK methods**:
284
284
 
285
285
  - `client.realtime_websocket_url(model="gpt-realtime-1.5")`
286
- - `client.realtime_websocket_headers(session_id="...")`
286
+ - `client.realtime.websocket_headers(session_id="...")`
287
287
 
288
288
  The URL helper returns:
289
289
 
@@ -291,10 +291,11 @@ The URL helper returns:
291
291
  wss://api.openai.com/v1/realtime?model=...
292
292
  ```
293
293
 
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.
294
+ The headers helper returns `Authorization: Bearer <openai_api_key>`, the Codex
295
+ originator, and the optional session id. Interactive ChatGPT OAuth attempts to
296
+ exchange its fresh ID token for this Realtime API key and persists it when the
297
+ account is entitled to one. If that exchange is unavailable, regular Codex OAuth
298
+ continues to work but Voice v2 requires a separately provisioned API key.
298
299
 
299
300
  ---
300
301
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-backend-sdk"
7
- version = "0.3.4"
7
+ version = "0.3.6"
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")
@@ -29,14 +29,13 @@ class FakeRealtimeClient(OpenAI):
29
29
  refresh_token="refresh-token",
30
30
  id_token_raw="id-token",
31
31
  account_id="account-id",
32
- openai_api_key="sk-test",
32
+ openai_api_key="realtime-key",
33
33
  ))
34
34
 
35
35
  def _post_raw(self, path, **kwargs):
36
36
  self.raw_posts.append((path, kwargs))
37
37
  return FakeResponse()
38
38
 
39
-
40
39
  def test_realtime_calls_create_posts_plain_sdp_like_official_sdk():
41
40
  client = FakeRealtimeClient()
42
41
 
@@ -68,13 +67,37 @@ def test_realtime_calls_create_posts_session_as_backend_json():
68
67
  assert kwargs["headers"]["Accept"] == "application/sdp"
69
68
 
70
69
 
71
- def test_realtime_websocket_helpers_match_codex_agent_plugin_contract():
70
+ def test_realtime_websocket_uses_api_key_exchanged_during_oauth():
72
71
  client = FakeRealtimeClient()
73
72
 
73
+ assert client.realtime.websocket_headers(session_id="session-123") == {
74
+ "Authorization": "Bearer realtime-key",
75
+ "originator": "codex_cli_rs",
76
+ "x-session-id": "session-123",
77
+ }
74
78
  assert client.realtime_websocket_url(model="gpt-realtime-1.5") == (
75
79
  "wss://api.openai.com/v1/realtime?model=gpt-realtime-1.5"
76
80
  )
77
- assert client.realtime_websocket_headers(session_id="session-123") == {
78
- "Authorization": "Bearer sk-test",
79
- "OpenAI-Beta": "realtime=v1",
80
- }
81
+
82
+
83
+ def test_realtime_websocket_falls_back_to_environment_api_key(monkeypatch):
84
+ client = FakeRealtimeClient()
85
+ client._store.openai_api_key = None
86
+ monkeypatch.setenv("OPENAI_API_KEY", "environment-key")
87
+
88
+ assert client.realtime.websocket_headers()["Authorization"] == (
89
+ "Bearer environment-key"
90
+ )
91
+
92
+
93
+ def test_realtime_websocket_requires_api_key(monkeypatch):
94
+ client = FakeRealtimeClient()
95
+ client._store.openai_api_key = None
96
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
97
+
98
+ try:
99
+ client.realtime.websocket_headers()
100
+ except RuntimeError as exc:
101
+ assert str(exc) == "Realtime Voice v2 requires an OpenAI API key."
102
+ else:
103
+ raise AssertionError("Expected missing Realtime credentials to fail")
@@ -39,6 +39,7 @@ class FakeClient(OpenAI):
39
39
  return FakeJSONResponse({
40
40
  "id": "resp_123",
41
41
  "output": [{"type": "message", "content": []}],
42
+ "usage": {"input_tokens": 11, "output_tokens": 7, "total_tokens": 18},
42
43
  })
43
44
  return FakeSSE([
44
45
  'data: {"type":"response.content_part.delta","delta":{"text":"hel"}}',
@@ -282,6 +283,10 @@ def test_responses_compact_sends_shared_request_fields():
282
283
  )
283
284
 
284
285
  assert compacted.id == "resp_123"
286
+ assert compacted.usage is not None
287
+ assert compacted.usage.input_tokens == 11
288
+ assert compacted.usage.output_tokens == 7
289
+ assert compacted.usage.total_tokens == 18
285
290
  path, payload, stream = client.posts[0]
286
291
  assert path == "/responses/compact"
287
292
  assert stream is False
@@ -295,7 +300,7 @@ def test_responses_compact_sends_shared_request_fields():
295
300
  "content": [{"type": "input_text", "text": "Long context"}],
296
301
  }
297
302
  ],
298
- "tools": [{"type": "web_search_preview"}],
303
+ "tools": [{"type": "web_search"}],
299
304
  "parallel_tool_calls": True,
300
305
  "reasoning": {"effort": "medium"},
301
306
  "service_tier": "priority",
@@ -1,13 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project will be documented in this file.
4
-
5
- ## [0.3.4] - 2026-05-15
6
-
7
- ### Added
8
- - Added `authenticate(interactive=False)` to allow non-interactive credential checks without triggering the browser OAuth flow.
9
- - Added `client.authenticated` for simple auth-state introspection.
10
- - Added `client.account_info()` to expose safe non-secret account metadata (`authenticated`, `account_id`, `email`, `plan_type`).
11
-
12
- ### Documentation
13
- - Documented the non-interactive authentication helpers in `README.md`.