langchain-codex-plus 0.0.1__py3-none-any.whl
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.
- langchain_codex_plus/__init__.py +77 -0
- langchain_codex_plus/codex_auth.py +452 -0
- langchain_codex_plus/codex_chat_model.py +1155 -0
- langchain_codex_plus/codex_protocol.py +803 -0
- langchain_codex_plus/py.typed +0 -0
- langchain_codex_plus/rate_limits.py +202 -0
- langchain_codex_plus-0.0.1.dist-info/METADATA +156 -0
- langchain_codex_plus-0.0.1.dist-info/RECORD +10 -0
- langchain_codex_plus-0.0.1.dist-info/WHEEL +4 -0
- langchain_codex_plus-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""langchain-codex-plus: LangChain ChatModel for OpenAI Codex Plus.
|
|
2
|
+
|
|
3
|
+
Wraps OpenAI's ChatGPT-account-backed Codex subscription protocol
|
|
4
|
+
(``chatgpt.com/backend-api/codex/responses``) as a LangChain
|
|
5
|
+
``BaseChatModel``. See README for what this is and isn't.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from langchain_codex_plus.codex_auth import (
|
|
10
|
+
CODEX_API_BASE,
|
|
11
|
+
CODEX_OAUTH_CLIENT_ID,
|
|
12
|
+
REFRESH_TOKEN_URL,
|
|
13
|
+
CodexAuth,
|
|
14
|
+
CodexAuthInvalidError,
|
|
15
|
+
CodexAuthNotFoundError,
|
|
16
|
+
CodexAuthRefreshError,
|
|
17
|
+
arefresh_codex_auth,
|
|
18
|
+
auth_file_path,
|
|
19
|
+
codex_home,
|
|
20
|
+
is_likely_expired,
|
|
21
|
+
load_codex_auth,
|
|
22
|
+
refresh_codex_auth,
|
|
23
|
+
)
|
|
24
|
+
from langchain_codex_plus.codex_chat_model import ChatCodexPlus
|
|
25
|
+
from langchain_codex_plus.codex_protocol import (
|
|
26
|
+
CodexCompletion,
|
|
27
|
+
CodexResponseError,
|
|
28
|
+
CodexToolCall,
|
|
29
|
+
SseEvent,
|
|
30
|
+
ToolChoice,
|
|
31
|
+
build_request_body,
|
|
32
|
+
consume_events,
|
|
33
|
+
parse_error_body,
|
|
34
|
+
parse_sse_stream,
|
|
35
|
+
)
|
|
36
|
+
from langchain_codex_plus.rate_limits import (
|
|
37
|
+
CodexCredits,
|
|
38
|
+
CodexQuotaWindow,
|
|
39
|
+
CodexRateLimits,
|
|
40
|
+
parse_codex_rate_limits,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.0.1"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# codex_auth
|
|
47
|
+
"CODEX_API_BASE",
|
|
48
|
+
"CODEX_OAUTH_CLIENT_ID",
|
|
49
|
+
"REFRESH_TOKEN_URL",
|
|
50
|
+
"CodexAuth",
|
|
51
|
+
"CodexAuthInvalidError",
|
|
52
|
+
"CodexAuthNotFoundError",
|
|
53
|
+
"CodexAuthRefreshError",
|
|
54
|
+
"arefresh_codex_auth",
|
|
55
|
+
"auth_file_path",
|
|
56
|
+
"codex_home",
|
|
57
|
+
"is_likely_expired",
|
|
58
|
+
"load_codex_auth",
|
|
59
|
+
"refresh_codex_auth",
|
|
60
|
+
# codex_protocol
|
|
61
|
+
"CodexCompletion",
|
|
62
|
+
"CodexResponseError",
|
|
63
|
+
"CodexToolCall",
|
|
64
|
+
"SseEvent",
|
|
65
|
+
"ToolChoice",
|
|
66
|
+
"build_request_body",
|
|
67
|
+
"consume_events",
|
|
68
|
+
"parse_error_body",
|
|
69
|
+
"parse_sse_stream",
|
|
70
|
+
# rate_limits
|
|
71
|
+
"CodexCredits",
|
|
72
|
+
"CodexQuotaWindow",
|
|
73
|
+
"CodexRateLimits",
|
|
74
|
+
"parse_codex_rate_limits",
|
|
75
|
+
# chat model
|
|
76
|
+
"ChatCodexPlus",
|
|
77
|
+
]
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Codex Plus OAuth credentials reader + refresher.
|
|
2
|
+
|
|
3
|
+
Codex CLI (``openai/codex``) persists OAuth state at
|
|
4
|
+
``$CODEX_HOME/auth.json`` (defaults to ``~/.codex/auth.json``) after
|
|
5
|
+
the user runs ``codex login`` and goes through the ChatGPT-account
|
|
6
|
+
browser flow. The file is mode 0600 and has shape::
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"auth_mode": "chatgpt",
|
|
10
|
+
"OPENAI_API_KEY": null | "sk-...",
|
|
11
|
+
"tokens": {
|
|
12
|
+
"access_token": "...",
|
|
13
|
+
"id_token": "...",
|
|
14
|
+
"refresh_token": "...",
|
|
15
|
+
"account_id": "..."
|
|
16
|
+
},
|
|
17
|
+
"last_refresh": "2026-05-21T01:02:18Z"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Refresh: the access token has ~1h TTL. When it expires, this module
|
|
21
|
+
can POST to ChatGPT's OAuth token endpoint with the refresh token to
|
|
22
|
+
get a new pair. Refresh tokens may rotate, so the response is written
|
|
23
|
+
back to ``auth.json`` atomically. The chat model auto-refreshes on 401.
|
|
24
|
+
|
|
25
|
+
References (verified against ``openai/codex`` source 2026-05-20):
|
|
26
|
+
|
|
27
|
+
* Storage struct: ``codex-rs/login/src/auth/storage.rs`` (``AuthDotJson``)
|
|
28
|
+
* Path resolution: ``codex_home.join("auth.json")``
|
|
29
|
+
* API base: ``https://chatgpt.com/backend-api``
|
|
30
|
+
* Refresh endpoint: ``https://auth.openai.com/oauth/token``
|
|
31
|
+
(override with ``CODEX_REFRESH_TOKEN_URL_OVERRIDE`` env var)
|
|
32
|
+
* OAuth client_id: ``app_EMoamEEZ73f0CkXaXp7hrann``
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
from dataclasses import dataclass, replace
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import httpx
|
|
43
|
+
|
|
44
|
+
CODEX_API_BASE = "https://chatgpt.com/backend-api"
|
|
45
|
+
"""Codex Plus protocol base. Distinct from ``api.openai.com``."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
REFRESH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
|
49
|
+
"""Default refresh endpoint (matches ``openai/codex`` constant).
|
|
50
|
+
Override with the ``CODEX_REFRESH_TOKEN_URL_OVERRIDE`` env var."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
57
|
+
"""OAuth client_id sent on refresh requests. Public value pulled
|
|
58
|
+
from the codex CLI source — same string the CLI uses, so refresh
|
|
59
|
+
requests look identical from the server's perspective."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CodexAuthNotFoundError(FileNotFoundError):
|
|
63
|
+
"""Raised when ``auth.json`` is missing — operator hasn't run
|
|
64
|
+
``codex login`` yet. The error message includes the expected path
|
|
65
|
+
so callers can surface it cleanly."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CodexAuthInvalidError(ValueError):
|
|
69
|
+
"""Raised when ``auth.json`` exists but doesn't contain a usable
|
|
70
|
+
OAuth bundle (e.g., API-key-only mode, or partially-written file).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CodexAuthRefreshError(RuntimeError):
|
|
75
|
+
"""Raised when an OAuth refresh request fails. The
|
|
76
|
+
:attr:`permanent` flag distinguishes:
|
|
77
|
+
|
|
78
|
+
* **Permanent**: refresh_token expired / revoked / already used.
|
|
79
|
+
Operator must re-run ``codex login`` — retry won't help.
|
|
80
|
+
* **Transient**: network error, 5xx, etc. Retry may succeed.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
message: str,
|
|
86
|
+
*,
|
|
87
|
+
permanent: bool = False,
|
|
88
|
+
code: str | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.permanent = permanent
|
|
92
|
+
self.code = code
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class CodexAuth:
|
|
97
|
+
"""OAuth bundle from ``~/.codex/auth.json``."""
|
|
98
|
+
|
|
99
|
+
auth_mode: str
|
|
100
|
+
"""``"chatgpt"`` for OAuth (subscription) mode, ``"apikey"`` for
|
|
101
|
+
API-key fallback (rare — Codex CLI primarily targets subscription)."""
|
|
102
|
+
|
|
103
|
+
access_token: str
|
|
104
|
+
"""Short-lived bearer for ``chatgpt.com/backend-api/...``. Codex
|
|
105
|
+
CLI refreshes it via the ``refresh_token``; consumers should treat
|
|
106
|
+
this as opaque and re-read the file when calls start returning 401."""
|
|
107
|
+
|
|
108
|
+
id_token: str | None
|
|
109
|
+
"""JWT identifying the ChatGPT account — used in some
|
|
110
|
+
``agent-identities/*`` flows. Not needed for ``/codex/models`` or
|
|
111
|
+
``/codex/responses``."""
|
|
112
|
+
|
|
113
|
+
refresh_token: str | None
|
|
114
|
+
"""Used to obtain a fresh ``access_token`` when the current one
|
|
115
|
+
expires. Refresh flow not implemented in this module — delegate to
|
|
116
|
+
``codex auth refresh``."""
|
|
117
|
+
|
|
118
|
+
account_id: str | None
|
|
119
|
+
"""Stable ChatGPT account identifier."""
|
|
120
|
+
|
|
121
|
+
last_refresh: datetime | None
|
|
122
|
+
"""When the access_token was last minted. Useful as a coarse
|
|
123
|
+
expiry heuristic (Codex's access tokens have ~1h TTL in practice;
|
|
124
|
+
treat anything older than 55min as suspect)."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def codex_home() -> Path:
|
|
128
|
+
"""Return ``$CODEX_HOME`` or ``~/.codex``. Mirrors codex CLI."""
|
|
129
|
+
env = os.environ.get("CODEX_HOME", "").strip()
|
|
130
|
+
if env:
|
|
131
|
+
return Path(env).expanduser()
|
|
132
|
+
return Path.home() / ".codex"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def auth_file_path(home: Path | None = None) -> Path:
|
|
136
|
+
return (home or codex_home()) / "auth.json"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_codex_auth(
|
|
140
|
+
path: Path | None = None, *, strict: bool = False
|
|
141
|
+
) -> CodexAuth | None:
|
|
142
|
+
"""Read ``auth.json`` and return a :class:`CodexAuth`.
|
|
143
|
+
|
|
144
|
+
Default behavior is **lenient** — returns ``None`` when:
|
|
145
|
+
|
|
146
|
+
* the file doesn't exist (operator hasn't run ``codex login``), or
|
|
147
|
+
* the file exists but lacks an OAuth bundle (API-key-only mode).
|
|
148
|
+
|
|
149
|
+
Pass ``strict=True`` to raise :class:`CodexAuthNotFoundError` /
|
|
150
|
+
:class:`CodexAuthInvalidError` instead. Use strict in code paths
|
|
151
|
+
where missing auth is a configuration bug (e.g., a chat model
|
|
152
|
+
explicitly constructed with intent to call Codex Plus).
|
|
153
|
+
|
|
154
|
+
Always raises on malformed JSON — that's a real bug, not a missing-
|
|
155
|
+
auth case.
|
|
156
|
+
"""
|
|
157
|
+
p = path or auth_file_path()
|
|
158
|
+
if not p.is_file():
|
|
159
|
+
if strict:
|
|
160
|
+
raise CodexAuthNotFoundError(
|
|
161
|
+
f"Codex Plus auth not found at {p}. "
|
|
162
|
+
f"Run `codex login` to authenticate, then retry."
|
|
163
|
+
)
|
|
164
|
+
return None
|
|
165
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
166
|
+
auth_mode = str(raw.get("auth_mode") or "")
|
|
167
|
+
tokens = raw.get("tokens") or {}
|
|
168
|
+
access_token = tokens.get("access_token")
|
|
169
|
+
if not access_token:
|
|
170
|
+
if strict:
|
|
171
|
+
raise CodexAuthInvalidError(
|
|
172
|
+
f"{p} exists but has no OAuth bundle "
|
|
173
|
+
f"(auth_mode={auth_mode!r}). "
|
|
174
|
+
f"Run `codex login` to re-authenticate."
|
|
175
|
+
)
|
|
176
|
+
return None
|
|
177
|
+
last_refresh_raw = raw.get("last_refresh")
|
|
178
|
+
last_refresh: datetime | None = None
|
|
179
|
+
if isinstance(last_refresh_raw, str):
|
|
180
|
+
# Codex writes ISO-8601 with trailing Z; Python <3.11 needs
|
|
181
|
+
# an explicit replace.
|
|
182
|
+
try:
|
|
183
|
+
last_refresh = datetime.fromisoformat(
|
|
184
|
+
last_refresh_raw.replace("Z", "+00:00")
|
|
185
|
+
)
|
|
186
|
+
except ValueError:
|
|
187
|
+
last_refresh = None
|
|
188
|
+
return CodexAuth(
|
|
189
|
+
auth_mode=auth_mode,
|
|
190
|
+
access_token=str(access_token),
|
|
191
|
+
id_token=tokens.get("id_token"),
|
|
192
|
+
refresh_token=tokens.get("refresh_token"),
|
|
193
|
+
account_id=tokens.get("account_id"),
|
|
194
|
+
last_refresh=last_refresh,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def is_likely_expired(
|
|
199
|
+
auth: CodexAuth, *, ttl_minutes: int = 55
|
|
200
|
+
) -> bool:
|
|
201
|
+
"""Coarse expiry heuristic. ChatGPT access tokens have ~1h TTL in
|
|
202
|
+
practice; default to 55min so we're conservative.
|
|
203
|
+
|
|
204
|
+
Returns ``True`` if ``last_refresh`` is older than ``ttl_minutes``
|
|
205
|
+
OR if ``last_refresh`` is missing entirely (can't prove freshness).
|
|
206
|
+
"""
|
|
207
|
+
if auth.last_refresh is None:
|
|
208
|
+
return True
|
|
209
|
+
age = datetime.now(tz=UTC) - auth.last_refresh
|
|
210
|
+
return age.total_seconds() > ttl_minutes * 60
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ─── Refresh ────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _refresh_endpoint() -> str:
|
|
217
|
+
return os.environ.get(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR) or REFRESH_TOKEN_URL
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _refresh_body(refresh_token: str) -> dict[str, str]:
|
|
221
|
+
return {
|
|
222
|
+
"client_id": CODEX_OAUTH_CLIENT_ID,
|
|
223
|
+
"grant_type": "refresh_token",
|
|
224
|
+
"refresh_token": refresh_token,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _apply_refresh_response(
|
|
229
|
+
auth: CodexAuth, response_data: dict
|
|
230
|
+
) -> CodexAuth:
|
|
231
|
+
"""Merge a successful refresh response into an existing
|
|
232
|
+
:class:`CodexAuth`. Per the codex CLI source, every field in the
|
|
233
|
+
response is optional — only fields present in the response replace
|
|
234
|
+
their counterpart on the auth bundle. The refresh_token may rotate
|
|
235
|
+
or stay the same.
|
|
236
|
+
"""
|
|
237
|
+
new_access = response_data.get("access_token")
|
|
238
|
+
new_id = response_data.get("id_token")
|
|
239
|
+
new_refresh = response_data.get("refresh_token")
|
|
240
|
+
if not isinstance(new_access, str) or not new_access:
|
|
241
|
+
raise CodexAuthRefreshError(
|
|
242
|
+
"Refresh response missing access_token",
|
|
243
|
+
permanent=False,
|
|
244
|
+
)
|
|
245
|
+
return replace(
|
|
246
|
+
auth,
|
|
247
|
+
access_token=new_access,
|
|
248
|
+
id_token=new_id if isinstance(new_id, str) else auth.id_token,
|
|
249
|
+
refresh_token=(
|
|
250
|
+
new_refresh
|
|
251
|
+
if isinstance(new_refresh, str)
|
|
252
|
+
else auth.refresh_token
|
|
253
|
+
),
|
|
254
|
+
last_refresh=datetime.now(tz=UTC),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _classify_refresh_error(
|
|
259
|
+
status_code: int, body: dict | None
|
|
260
|
+
) -> CodexAuthRefreshError:
|
|
261
|
+
"""Map an OAuth token-endpoint error response to a typed error.
|
|
262
|
+
|
|
263
|
+
Per the codex CLI's classification (``manager.rs``), these
|
|
264
|
+
error codes mean the user must log in again:
|
|
265
|
+
|
|
266
|
+
* ``expired_token`` / ``invalid_grant`` — refresh_token expired
|
|
267
|
+
* ``refresh_token_reused`` — already-used token
|
|
268
|
+
* ``invalid_token`` — token revoked
|
|
269
|
+
* 4xx generally → permanent (auth-side problem)
|
|
270
|
+
* 5xx → transient (server-side, retry may work)
|
|
271
|
+
"""
|
|
272
|
+
code = None
|
|
273
|
+
message = f"HTTP {status_code} from {_refresh_endpoint()}"
|
|
274
|
+
if isinstance(body, dict):
|
|
275
|
+
err = body.get("error")
|
|
276
|
+
desc = body.get("error_description") or body.get("detail")
|
|
277
|
+
if isinstance(err, str):
|
|
278
|
+
code = err
|
|
279
|
+
message = f"{err}: {desc}" if desc else err
|
|
280
|
+
elif isinstance(desc, str):
|
|
281
|
+
message = desc
|
|
282
|
+
permanent_codes = {
|
|
283
|
+
"expired_token",
|
|
284
|
+
"invalid_grant",
|
|
285
|
+
"invalid_token",
|
|
286
|
+
"refresh_token_reused",
|
|
287
|
+
"refresh_token_invalidated",
|
|
288
|
+
}
|
|
289
|
+
permanent = (
|
|
290
|
+
(code in permanent_codes)
|
|
291
|
+
or (400 <= status_code < 500 and status_code != 429)
|
|
292
|
+
)
|
|
293
|
+
return CodexAuthRefreshError(message, permanent=permanent, code=code)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _write_auth_json(path: Path, auth: CodexAuth) -> None:
|
|
297
|
+
"""Atomically rewrite ``auth.json`` with refreshed tokens.
|
|
298
|
+
|
|
299
|
+
Reads the existing file (if any) to preserve fields we don't
|
|
300
|
+
track in :class:`CodexAuth` (``OPENAI_API_KEY``, ``agent_identity``,
|
|
301
|
+
``auth_mode``-but-set-via-explicit-API-key etc.); then merges the
|
|
302
|
+
refreshed tokens in and writes via a ``.tmp``-then-rename so
|
|
303
|
+
crashes during write can't leave the file corrupted.
|
|
304
|
+
"""
|
|
305
|
+
existing: dict = {}
|
|
306
|
+
if path.is_file():
|
|
307
|
+
try:
|
|
308
|
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
|
309
|
+
except json.JSONDecodeError:
|
|
310
|
+
# Corrupted file — start fresh rather than crash.
|
|
311
|
+
existing = {}
|
|
312
|
+
existing["auth_mode"] = auth.auth_mode or existing.get("auth_mode") or "chatgpt"
|
|
313
|
+
tokens = existing.get("tokens") or {}
|
|
314
|
+
tokens["access_token"] = auth.access_token
|
|
315
|
+
if auth.id_token is not None:
|
|
316
|
+
tokens["id_token"] = auth.id_token
|
|
317
|
+
if auth.refresh_token is not None:
|
|
318
|
+
tokens["refresh_token"] = auth.refresh_token
|
|
319
|
+
if auth.account_id is not None:
|
|
320
|
+
tokens["account_id"] = auth.account_id
|
|
321
|
+
existing["tokens"] = tokens
|
|
322
|
+
if auth.last_refresh is not None:
|
|
323
|
+
existing["last_refresh"] = (
|
|
324
|
+
auth.last_refresh.astimezone(UTC)
|
|
325
|
+
.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
326
|
+
)
|
|
327
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
328
|
+
tmp.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
329
|
+
try:
|
|
330
|
+
tmp.chmod(0o600)
|
|
331
|
+
except OSError:
|
|
332
|
+
# On filesystems that don't support chmod (FAT, Docker
|
|
333
|
+
# bind-mounts) this is a non-fatal hint; skip.
|
|
334
|
+
pass
|
|
335
|
+
tmp.replace(path)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def refresh_codex_auth(
|
|
339
|
+
auth: CodexAuth,
|
|
340
|
+
*,
|
|
341
|
+
path: Path | None = None,
|
|
342
|
+
write_back: bool = True,
|
|
343
|
+
timeout_seconds: float = 30.0,
|
|
344
|
+
http_client: httpx.Client | None = None,
|
|
345
|
+
) -> CodexAuth:
|
|
346
|
+
"""POST to the OAuth token endpoint to refresh ``auth``.
|
|
347
|
+
|
|
348
|
+
Returns a new :class:`CodexAuth` with the rotated tokens. By
|
|
349
|
+
default also writes the result back to ``auth.json`` (atomic
|
|
350
|
+
rename via ``.tmp``) — pass ``write_back=False`` to skip the file
|
|
351
|
+
update (useful for tests).
|
|
352
|
+
|
|
353
|
+
Raises :class:`CodexAuthRefreshError`. The ``permanent`` attribute
|
|
354
|
+
distinguishes errors that the operator must fix (re-run ``codex
|
|
355
|
+
login``) from transient ones worth retrying.
|
|
356
|
+
"""
|
|
357
|
+
if not auth.refresh_token:
|
|
358
|
+
raise CodexAuthRefreshError(
|
|
359
|
+
"No refresh_token available — operator must run "
|
|
360
|
+
"`codex login` to authenticate.",
|
|
361
|
+
permanent=True,
|
|
362
|
+
)
|
|
363
|
+
endpoint = _refresh_endpoint()
|
|
364
|
+
body = _refresh_body(auth.refresh_token)
|
|
365
|
+
if http_client is None:
|
|
366
|
+
with httpx.Client(timeout=timeout_seconds) as client:
|
|
367
|
+
response = _do_refresh_sync(client, endpoint, body)
|
|
368
|
+
else:
|
|
369
|
+
response = _do_refresh_sync(http_client, endpoint, body)
|
|
370
|
+
updated = _apply_refresh_response(auth, response)
|
|
371
|
+
if write_back:
|
|
372
|
+
_write_auth_json(path or auth_file_path(), updated)
|
|
373
|
+
return updated
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def arefresh_codex_auth(
|
|
377
|
+
auth: CodexAuth,
|
|
378
|
+
*,
|
|
379
|
+
path: Path | None = None,
|
|
380
|
+
write_back: bool = True,
|
|
381
|
+
timeout_seconds: float = 30.0,
|
|
382
|
+
http_client: httpx.AsyncClient | None = None,
|
|
383
|
+
) -> CodexAuth:
|
|
384
|
+
"""Async sibling of :func:`refresh_codex_auth`. Same contract."""
|
|
385
|
+
if not auth.refresh_token:
|
|
386
|
+
raise CodexAuthRefreshError(
|
|
387
|
+
"No refresh_token available — operator must run "
|
|
388
|
+
"`codex login` to authenticate.",
|
|
389
|
+
permanent=True,
|
|
390
|
+
)
|
|
391
|
+
endpoint = _refresh_endpoint()
|
|
392
|
+
body = _refresh_body(auth.refresh_token)
|
|
393
|
+
if http_client is None:
|
|
394
|
+
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
|
395
|
+
response = await _do_refresh_async(client, endpoint, body)
|
|
396
|
+
else:
|
|
397
|
+
response = await _do_refresh_async(http_client, endpoint, body)
|
|
398
|
+
updated = _apply_refresh_response(auth, response)
|
|
399
|
+
if write_back:
|
|
400
|
+
_write_auth_json(path or auth_file_path(), updated)
|
|
401
|
+
return updated
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _do_refresh_sync(
|
|
405
|
+
client: httpx.Client, endpoint: str, body: dict
|
|
406
|
+
) -> dict:
|
|
407
|
+
try:
|
|
408
|
+
response = client.post(
|
|
409
|
+
endpoint, json=body, headers={"Content-Type": "application/json"}
|
|
410
|
+
)
|
|
411
|
+
except httpx.HTTPError as exc:
|
|
412
|
+
raise CodexAuthRefreshError(
|
|
413
|
+
f"Network error during refresh: {exc}", permanent=False
|
|
414
|
+
) from exc
|
|
415
|
+
return _handle_refresh_response(response)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
async def _do_refresh_async(
|
|
419
|
+
client: httpx.AsyncClient, endpoint: str, body: dict
|
|
420
|
+
) -> dict:
|
|
421
|
+
try:
|
|
422
|
+
response = await client.post(
|
|
423
|
+
endpoint, json=body, headers={"Content-Type": "application/json"}
|
|
424
|
+
)
|
|
425
|
+
except httpx.HTTPError as exc:
|
|
426
|
+
raise CodexAuthRefreshError(
|
|
427
|
+
f"Network error during refresh: {exc}", permanent=False
|
|
428
|
+
) from exc
|
|
429
|
+
return _handle_refresh_response(response)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _handle_refresh_response(response: httpx.Response) -> dict:
|
|
433
|
+
if 200 <= response.status_code < 300:
|
|
434
|
+
try:
|
|
435
|
+
data = response.json()
|
|
436
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
437
|
+
raise CodexAuthRefreshError(
|
|
438
|
+
f"Refresh response was not valid JSON: {exc}",
|
|
439
|
+
permanent=False,
|
|
440
|
+
) from exc
|
|
441
|
+
if not isinstance(data, dict):
|
|
442
|
+
raise CodexAuthRefreshError(
|
|
443
|
+
"Refresh response was not a JSON object",
|
|
444
|
+
permanent=False,
|
|
445
|
+
)
|
|
446
|
+
return data
|
|
447
|
+
body: dict | None = None
|
|
448
|
+
try:
|
|
449
|
+
body = response.json()
|
|
450
|
+
except (json.JSONDecodeError, ValueError):
|
|
451
|
+
body = None
|
|
452
|
+
raise _classify_refresh_error(response.status_code, body)
|