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.
@@ -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)