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.
- codex_backend_sdk-0.3.6/CHANGELOG.md +34 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/PKG-INFO +9 -8
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/README.md +8 -7
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/__init__.py +2 -3
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_client.py +17 -20
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_models.py +1 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/oauth.py +22 -52
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/_responses_payloads.py +1 -7
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/realtime.py +16 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/responses.py +7 -1
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/storage.py +4 -4
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/docs/backend-api.md +6 -5
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/pyproject.toml +1 -1
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_account_info.py +19 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_realtime_resource.py +30 -7
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_responses_resource.py +6 -1
- codex_backend_sdk-0.3.4/CHANGELOG.md +0 -13
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/.gitignore +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/LICENSE +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_streaming.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_transport.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/_utils.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/codex_client.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/pkce.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/__init__.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/codex.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/files.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/models.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/openai_oauth.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/examples/agent.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/conftest.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_basic.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_client_retry.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_codex_resources.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_conversation.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_files_resource.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_openai_oauth_resources.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_reasoning.py +0 -0
- {codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/tests/test_structured_output.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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:
|
|
@@ -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
|
-
|
|
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:
|
{codex_backend_sdk-0.3.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/responses.py
RENAMED
|
@@ -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(
|
|
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
|
-
|
|
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")
|
|
@@ -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": "
|
|
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`.
|
|
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.4 → codex_backend_sdk-0.3.6}/codex_backend_sdk/resources/openai_oauth.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
|