codex-backend-sdk 0.3.5__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.
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/CHANGELOG.md +13 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/PKG-INFO +9 -8
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/README.md +8 -7
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/__init__.py +2 -3
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_client.py +17 -20
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/oauth.py +22 -52
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/realtime.py +16 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/storage.py +4 -4
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/docs/backend-api.md +6 -5
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/pyproject.toml +1 -1
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_account_info.py +19 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_realtime_resource.py +30 -7
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/.gitignore +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/LICENSE +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_models.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_streaming.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_transport.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_utils.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/codex_client.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/pkce.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/__init__.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/_responses_payloads.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/codex.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/files.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/models.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/openai_oauth.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/responses.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/examples/agent.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/conftest.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_basic.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_client_retry.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_codex_resources.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_conversation.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_files_resource.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_openai_oauth_resources.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_reasoning.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_responses_resource.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_structured_output.py +0 -0
- {codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/tests/test_tools.py +0 -0
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
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
|
+
|
|
5
18
|
## [0.3.5] - 2026-05-18
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-backend-sdk
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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
|
|
347
|
-
|
|
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.
|
|
351
|
+
headers = client.realtime.websocket_headers(session_id="voice-session")
|
|
352
352
|
```
|
|
353
353
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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.
|
|
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
|
|
321
|
-
|
|
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.
|
|
325
|
+
headers = client.realtime.websocket_headers(session_id="voice-session")
|
|
326
326
|
```
|
|
327
327
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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.
|
|
20
|
+
__version__ = "0.3.6"
|
|
21
21
|
|
|
22
|
-
from .oauth import run_oauth_flow, refresh_access_token
|
|
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=
|
|
80
|
-
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
|
-
|
|
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(
|
|
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
|
|
@@ -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
|
|
154
|
-
"""
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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,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:
|
|
@@ -66,7 +66,7 @@ class TokenStore:
|
|
|
66
66
|
access_token: str,
|
|
67
67
|
refresh_token: str,
|
|
68
68
|
id_token: str,
|
|
69
|
-
|
|
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=
|
|
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
|
-
"
|
|
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.
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
|
@@ -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="
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/openai_oauth.py
RENAMED
|
File without changes
|
{codex_backend_sdk-0.3.5 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/responses.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|