1
+from __future__ import annotations
2
+
3
+import base64
4
+import hashlib
5
+import json
6
+import os
7
+import secrets
8
+import subprocess
9
+import time
10
+from dataclasses import dataclass
11
+from datetime import datetime, timedelta, timezone
12
+from pathlib import Path
13
+from typing import Any, Iterable
14
+from urllib.parse import parse_qs, urlencode, urljoin, urlparse
15
+
16
+import requests
17
+
18
+from helpers import files
19
+from plugins._oauth.helpers.config import codex_config
20
+
21
+
22
+AUTH_FILENAME = "auth.json"
23
+ACCESS_EXPIRY_MARGIN = timedelta(minutes=5)
24
+REFRESH_INTERVAL = timedelta(minutes=55)
25
+FALLBACK_CODEX_VERSION = "0.124.0"
26
+OAUTH_ERROR_KEYS = {"error", "error_description"}
27
+DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
28
+
29
+
30
+@dataclass(frozen=True)
31
+class PkcePair:
32
+ verifier: str
33
+ challenge: str
34
+
35
+
36
+@dataclass(frozen=True)
37
+class EffectiveAuth:
38
+ access_token: str
39
+ account_id: str
40
+ id_token: str = ""
41
+ refresh_token: str = ""
42
+ source_path: str = ""
43
+ last_refresh: str = ""
44
+
45
+
46
+def generate_pkce() -> PkcePair:
47
+ verifier = _base64url(secrets.token_bytes(64))
48
+ challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
49
+ return PkcePair(verifier=verifier, challenge=challenge)
50
+
51
+
52
+def generate_state() -> str:
53
+ return _base64url(secrets.token_bytes(32))
54
+
55
+
56
+def build_authorize_url(redirect_uri: str, state: str, pkce: PkcePair) -> str:
57
+ cfg = codex_config()
58
+ query = {
59
+ "response_type": "code",
60
+ "client_id": cfg["client_id"],
61
+ "redirect_uri": redirect_uri,
62
+ "scope": " ".join(cfg["scopes"]),
63
+ "code_challenge": pkce.challenge,
64
+ "code_challenge_method": "S256",
65
+ "id_token_add_organizations": "true",
66
+ "codex_cli_simplified_flow": "true",
67
+ "state": state,
68
+ "originator": "codex_cli_rs",
69
+ }
70
+ if cfg["forced_workspace_id"]:
71
+ query["allowed_workspace_id"] = cfg["forced_workspace_id"]
72
+
73
+ return f'{cfg["issuer"]}/oauth/authorize?{urlencode(query)}'
74
+
75
+
76
+def exchange_code_for_tokens(
77
+ code: str,
78
+ redirect_uri: str,
79
+ verifier: str,
80
+) -> dict[str, str]:
81
+ cfg = codex_config()
82
+ response = requests.post(
83
+ cfg["token_url"],
84
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
85
+ data={
86
+ "grant_type": "authorization_code",
87
+ "code": code,
88
+ "redirect_uri": redirect_uri,
89
+ "client_id": cfg["client_id"],
90
+ "code_verifier": verifier,
91
+ },
92
+ timeout=30,
93
+ )
94
+ if not response.ok:
95
+ raise RuntimeError(_token_error_message(response))
96
+
97
+ payload = response.json()
98
+ if not isinstance(payload, dict):
99
+ raise RuntimeError("OAuth token endpoint returned a malformed response.")
100
+
101
+ tokens = {
102
+ "id_token": str(payload.get("id_token") or ""),
103
+ "access_token": str(payload.get("access_token") or ""),
104
+ "refresh_token": str(payload.get("refresh_token") or ""),
105
+ }
106
+ missing = [key for key, value in tokens.items() if not value]
107
+ if missing:
108
+ raise RuntimeError(f"OAuth token response is missing: {', '.join(missing)}")
109
+
110
+ return tokens
111
+
112
+
113
+def obtain_api_key(id_token: str) -> str:
114
+ cfg = codex_config()
115
+ response = requests.post(
116
+ cfg["token_url"],
117
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
118
+ data={
119
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
120
+ "client_id": cfg["client_id"],
121
+ "requested_token": "openai-api-key",
122
+ "subject_token": id_token,
123
+ "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
124
+ },
125
+ timeout=30,
126
+ )
127
+ if not response.ok:
128
+ raise RuntimeError(f"API-key token exchange failed with status {response.status_code}.")
129
+ payload = response.json()
130
+ if not isinstance(payload, dict) or not payload.get("access_token"):
131
+ raise RuntimeError("API-key token exchange returned a malformed response.")
132
+ return str(payload["access_token"])
133
+
134
+
135
+def complete_login(code: str, redirect_uri: str, verifier: str) -> EffectiveAuth:
136
+ tokens = exchange_code_for_tokens(code, redirect_uri, verifier)
137
+ return persist_exchanged_tokens(tokens)
138
+
139
+
140
+def persist_exchanged_tokens(tokens: dict[str, str]) -> EffectiveAuth:
141
+ id_token = tokens["id_token"]
142
+ account_id = derive_account_id(id_token)
143
+ if not account_id:
144
+ raise RuntimeError("OAuth ID token did not include a ChatGPT account id.")
145
+
146
+ cfg = codex_config()
147
+ if cfg["forced_workspace_id"] and account_id != cfg["forced_workspace_id"]:
148
+ raise RuntimeError(
149
+ f'Login is restricted to workspace id {cfg["forced_workspace_id"]}.'
150
+ )
151
+
152
+ try:
153
+ api_key = obtain_api_key(id_token)
154
+ except Exception:
155
+ api_key = ""
156
+
157
+ auth_data = {
158
+ "auth_mode": "chatgpt",
159
+ "OPENAI_API_KEY": api_key or None,
160
+ "tokens": {
161
+ "id_token": id_token,
162
+ "access_token": tokens["access_token"],
163
+ "refresh_token": tokens["refresh_token"],
164
+ "account_id": account_id,
165
+ },
166
+ "last_refresh": utc_now_iso(),
167
+ }
168
+ path = resolve_auth_write_path()
169
+ write_auth_file(path, auth_data)
170
+ return load_auth(ensure_fresh=False)
171
+
172
+
173
+def request_device_code() -> dict[str, Any]:
174
+ cfg = codex_config()
175
+ base_url = cfg["issuer"].rstrip("/")
176
+ response = requests.post(
177
+ f"{base_url}/api/accounts/deviceauth/usercode",
178
+ headers={"Content-Type": "application/json"},
179
+ json={"client_id": cfg["client_id"]},
180
+ timeout=30,
181
+ )
182
+ if not response.ok:
183
+ raise RuntimeError(_token_error_message(response))
184
+
185
+ payload = response.json()
186
+ if not isinstance(payload, dict):
187
+ raise RuntimeError("Device authorization returned a malformed response.")
188
+
189
+ device_auth_id = _string(payload.get("device_auth_id"))
190
+ user_code = _string(payload.get("user_code") or payload.get("usercode"))
191
+ if not device_auth_id or not user_code:
192
+ raise RuntimeError("Device authorization response did not include a code.")
193
+
194
+ interval = _safe_int(payload.get("interval"), 5)
195
+ expires_at = _device_expires_at(payload.get("expires_at"))
196
+ return {
197
+ "device_auth_id": device_auth_id,
198
+ "user_code": user_code,
199
+ "interval": interval,
200
+ "expires_at": expires_at,
201
+ "verification_url": f"{base_url}/codex/device",
202
+ }
203
+
204
+
205
+def poll_device_authorization(device_auth_id: str, user_code: str) -> dict[str, Any]:
206
+ cfg = codex_config()
207
+ base_url = cfg["issuer"].rstrip("/")
208
+ response = requests.post(
209
+ f"{base_url}/api/accounts/deviceauth/token",
210
+ headers={"Content-Type": "application/json"},
211
+ json={"device_auth_id": device_auth_id, "user_code": user_code},
212
+ timeout=30,
213
+ )
214
+
215
+ if response.status_code in {403, 404}:
216
+ return {"completed": False}
217
+ if not response.ok:
218
+ raise RuntimeError(_token_error_message(response))
219
+
220
+ payload = response.json()
221
+ if not isinstance(payload, dict):
222
+ raise RuntimeError("Device authorization token response was malformed.")
223
+ authorization_code = _string(payload.get("authorization_code"))
224
+ verifier = _string(payload.get("code_verifier"))
225
+ if not authorization_code or not verifier:
226
+ raise RuntimeError("Device authorization response was missing token exchange data.")
227
+
228
+ tokens = exchange_code_for_tokens(
229
+ authorization_code,
230
+ f"{base_url}/deviceauth/callback",
231
+ verifier,
232
+ )
233
+ auth = persist_exchanged_tokens(tokens)
234
+ return {"completed": True, "account_id": auth.account_id}
235
+
236
+
237
+def load_auth(*, ensure_fresh: bool = True) -> EffectiveAuth:
238
+ path, data = read_auth_file()
239
+ tokens = data.get("tokens") if isinstance(data, dict) else {}
240
+ tokens = tokens if isinstance(tokens, dict) else {}
241
+
242
+ access_token = _string(tokens.get("access_token"))
243
+ id_token = _string(tokens.get("id_token"))
244
+ refresh_token = _string(tokens.get("refresh_token"))
245
+ account_id = _string(tokens.get("account_id")) or derive_account_id(id_token)
246
+ last_refresh = _string(data.get("last_refresh")) if isinstance(data, dict) else ""
247
+
248
+ if ensure_fresh and refresh_token and should_refresh(access_token, last_refresh):
249
+ refreshed = refresh_tokens(refresh_token)
250
+ access_token = refreshed.get("access_token") or access_token
251
+ id_token = refreshed.get("id_token") or id_token
252
+ refresh_token = refreshed.get("refresh_token") or refresh_token
253
+ account_id = derive_account_id(id_token) or account_id
254
+ last_refresh = utc_now_iso()
255
+ data["tokens"] = {
256
+ "id_token": id_token,
257
+ "access_token": access_token,
258
+ "refresh_token": refresh_token,
259
+ "account_id": account_id,
260
+ }
261
+ data["last_refresh"] = last_refresh
262
+ write_auth_file(path, data)
263
+
264
+ if not access_token:
265
+ raise RuntimeError("Codex/ChatGPT account access token not found. Connect the account first.")
266
+ if not account_id:
267
+ raise RuntimeError("Codex/ChatGPT account id not found. Connect the account again.")
268
+
269
+ return EffectiveAuth(
270
+ access_token=access_token,
271
+ account_id=account_id,
272
+ id_token=id_token,
273
+ refresh_token=refresh_token,
274
+ source_path=str(path),
275
+ last_refresh=last_refresh,
276
+ )
277
+
278
+
279
+def status() -> dict[str, Any]:
280
+ candidates = resolve_auth_file_candidates()
281
+ existing = [str(path) for path in candidates if path.is_file()]
282
+ result: dict[str, Any] = {
283
+ "connected": False,
284
+ "auth_file_path": str(resolve_auth_write_path()),
285
+ "discovered_auth_files": existing,
286
+ }
287
+ try:
288
+ auth = load_auth(ensure_fresh=False)
289
+ except Exception as exc:
290
+ result["message"] = str(exc)
291
+ return result
292
+
293
+ id_claims = parse_jwt_claims(auth.id_token)
294
+ access_claims = parse_jwt_claims(auth.access_token)
295
+ auth_claims = _auth_claims(id_claims)
296
+ result.update(
297
+ {
298
+ "connected": True,
299
+ "auth_file_path": auth.source_path,
300
+ "account_id": auth.account_id,
301
+ "email": id_claims.get("email")
302
+ or _record(id_claims.get("https://api.openai.com/profile")).get("email"),
303
+ "plan_type": auth_claims.get("chatgpt_plan_type"),
304
+ "user_id": auth_claims.get("chatgpt_user_id") or auth_claims.get("user_id"),
305
+ "access_expires_at": _jwt_expiration_iso(access_claims),
306
+ "last_refresh": auth.last_refresh,
307
+ }
308
+ )
309
+ return result
310
+
311
+
312
+def refresh_tokens(refresh_token: str) -> dict[str, str]:
313
+ cfg = codex_config()
314
+ response = requests.post(
315
+ cfg["token_url"],
316
+ headers={"Content-Type": "application/json"},
317
+ json={
318
+ "client_id": cfg["client_id"],
319
+ "grant_type": "refresh_token",
320
+ "refresh_token": refresh_token,
321
+ },
322
+ timeout=30,
323
+ )
324
+ if not response.ok:
325
+ raise RuntimeError(_token_error_message(response))
326
+
327
+ payload = response.json()
328
+ if not isinstance(payload, dict):
329
+ raise RuntimeError("OAuth refresh endpoint returned a malformed response.")
330
+
331
+ return {
332
+ "id_token": _string(payload.get("id_token")),
333
+ "access_token": _string(payload.get("access_token")),
334
+ "refresh_token": _string(payload.get("refresh_token")) or refresh_token,
335
+ }
336
+
337
+
338
+def should_refresh(access_token: str, last_refresh: str) -> bool:
339
+ if not access_token:
340
+ return True
341
+
342
+ claims = parse_jwt_claims(access_token)
343
+ exp = claims.get("exp")
344
+ if isinstance(exp, (int, float)):
345
+ expires_at = datetime.fromtimestamp(float(exp), tz=timezone.utc)
346
+ if expires_at <= datetime.now(timezone.utc) + ACCESS_EXPIRY_MARGIN:
347
+ return True
348
+
349
+ refreshed_at = parse_iso(last_refresh)
350
+ if refreshed_at is not None:
351
+ return refreshed_at <= datetime.now(timezone.utc) - REFRESH_INTERVAL
352
+ return False
353
+
354
+
355
+def request_codex(
356
+ path: str,
357
+ *,
358
+ method: str = "GET",
359
+ headers: dict[str, str] | None = None,
360
+ body: bytes | str | None = None,
361
+ stream: bool = False,
362
+ params: dict[str, str] | None = None,
363
+) -> requests.Response:
364
+ cfg = codex_config()
365
+ auth = load_auth()
366
+ target = build_upstream_url(path, cfg["upstream_base_url"])
367
+ request_headers = sanitize_forward_headers(headers or {})
368
+ request_headers.update(
369
+ {
370
+ "Authorization": f"Bearer {auth.access_token}",
371
+ "chatgpt-account-id": auth.account_id,
372
+ "OpenAI-Beta": "responses=experimental",
373
+ }
374
+ )
375
+
376
+ return requests.request(
377
+ method,
378
+ target,
379
+ headers=request_headers,
380
+ data=body,
381
+ params=params,
382
+ timeout=max(5, cfg["request_timeout_seconds"]),
383
+ stream=stream,
384
+ )
385
+
386
+
387
+def fetch_models() -> list[str]:
388
+ cfg = codex_config()
389
+ configured = cfg["models"]
390
+ if configured:
391
+ return configured
392
+
393
+ response = request_codex(
394
+ "/models",
395
+ params={"client_version": resolve_codex_version()},
396
+ )
397
+ if not response.ok:
398
+ raise RuntimeError(upstream_error_message(response, "Failed to load Codex models."))
399
+
400
+ payload = response.json()
401
+ raw_models = payload.get("models") if isinstance(payload, dict) else None
402
+ if not isinstance(raw_models, list):
403
+ raise RuntimeError("Codex returned a malformed models response.")
404
+
405
+ models: list[str] = []
406
+ seen: set[str] = set()
407
+ for item in raw_models:
408
+ slug = item.get("slug") if isinstance(item, dict) else None
409
+ if isinstance(slug, str) and slug and slug not in seen:
410
+ seen.add(slug)
411
+ models.append(slug)
412
+ if not models:
413
+ raise RuntimeError("Codex returned an empty models list.")
414
+ return models
415
+
416
+
417
+def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]:
418
+ normalized = dict(body)
419
+ normalized.setdefault("instructions", "")
420
+ normalized.setdefault("store", False)
421
+ if force_stream:
422
+ normalized["stream"] = True
423
+ normalized.pop("max_output_tokens", None)
424
+ return normalized
425
+
426
+
427
+def collect_completed_response(response: requests.Response) -> dict[str, Any]:
428
+ latest_response: dict[str, Any] | None = None
429
+ latest_error: Any = None
430
+ text_pieces: list[str] = []
431
+ latest_usage: dict[str, Any] | None = None
432
+ for event in iter_sse_events(response):
433
+ data = event.get("data")
434
+ if not data:
435
+ continue
436
+ try:
437
+ parsed = json.loads(data)
438
+ except json.JSONDecodeError:
439
+ continue
440
+ if not isinstance(parsed, dict):
441
+ continue
442
+ if event.get("event") == "error":
443
+ latest_error = parsed
444
+ continue
445
+ text_pieces.extend(extract_sse_text_deltas(parsed, event.get("event", "")))
446
+ usage = parsed.get("usage")
447
+ if isinstance(usage, dict):
448
+ latest_usage = usage
449
+ candidate = parsed.get("response")
450
+ if isinstance(candidate, dict):
451
+ latest_response = candidate
452
+
453
+ if latest_response is not None:
454
+ return latest_response
455
+ if text_pieces:
456
+ result: dict[str, Any] = {"output_text": "".join(text_pieces)}
457
+ if latest_usage:
458
+ result["usage"] = latest_usage
459
+ return result
460
+ suffix = f" Last error: {json.dumps(latest_error)}" if latest_error else ""
461
+ raise RuntimeError(f"No completed response found in Codex SSE stream.{suffix}")
462
+
463
+
464
+def iter_sse_events(response: requests.Response) -> Iterable[dict[str, str]]:
465
+ buffer = ""
466
+ for chunk in response.iter_content(chunk_size=8192, decode_unicode=True):
467
+ if not chunk:
468
+ continue
469
+ buffer += chunk
470
+ while "\n\n" in buffer or "\r\n\r\n" in buffer:
471
+ sep = "\r\n\r\n" if "\r\n\r\n" in buffer else "\n\n"
472
+ block, buffer = buffer.split(sep, 1)
473
+ event = parse_sse_block(block)
474
+ if event:
475
+ yield event
476
+ event = parse_sse_block(buffer)
477
+ if event:
478
+ yield event
479
+
480
+
481
+def parse_sse_block(block: str) -> dict[str, str]:
482
+ event: dict[str, str] = {}
483
+ data_lines: list[str] = []
484
+ for line in block.splitlines():
485
+ if line.startswith("event:"):
486
+ event["event"] = line[6:].strip()
487
+ elif line.startswith("data:"):
488
+ data_lines.append(line[5:].lstrip())
489
+ if data_lines:
490
+ event["data"] = "\n".join(data_lines)
491
+ return event
492
+
493
+
494
+def extract_sse_text_deltas(payload: dict[str, Any], event_type: str = "") -> list[str]:
495
+ pieces: list[str] = []
496
+
497
+ choices = payload.get("choices")
498
+ if isinstance(choices, list):
499
+ for choice in choices:
500
+ if not isinstance(choice, dict):
501
+ continue
502
+ delta = choice.get("delta")
503
+ if isinstance(delta, dict):
504
+ _append_text_value(pieces, delta.get("content"))
505
+ elif isinstance(delta, str):
506
+ pieces.append(delta)
507
+
508
+ message = choice.get("message")
509
+ if isinstance(message, dict):
510
+ _append_text_value(pieces, message.get("content"))
511
+
512
+ delta = payload.get("delta")
513
+ if isinstance(delta, str):
514
+ pieces.append(delta)
515
+ elif isinstance(delta, dict):
516
+ _append_text_value(pieces, delta.get("content"))
517
+ _append_text_value(pieces, delta.get("text"))
518
+
519
+ if (payload.get("type") or event_type) in {
520
+ "response.output_text.delta",
521
+ "response.text.delta",
522
+ }:
523
+ _append_text_value(pieces, payload.get("text"))
524
+
525
+ return [piece for piece in pieces if piece]
526
+
527
+
528
+def _append_text_value(pieces: list[str], value: Any) -> None:
529
+ if isinstance(value, str):
530
+ pieces.append(value)
531
+ return
532
+ if isinstance(value, list):
533
+ for item in value:
534
+ if isinstance(item, str):
535
+ pieces.append(item)
536
+ elif isinstance(item, dict):
537
+ _append_text_value(pieces, item.get("text"))
538
+ _append_text_value(pieces, item.get("content"))
539
+
540
+
541
+def chat_messages_to_response_body(body: dict[str, Any]) -> dict[str, Any]:
542
+ messages = body.get("messages")
543
+ if not isinstance(messages, list):
544
+ raise RuntimeError("`messages` must be an array.")
545
+ if body.get("tools"):
546
+ raise RuntimeError("Codex/ChatGPT account wrapper does not yet support tool calls.")
547
+
548
+ instructions: list[str] = []
549
+ response_input: list[dict[str, Any]] = []
550
+ for message in messages:
551
+ if not isinstance(message, dict):
552
+ continue
553
+ role = str(message.get("role") or "user")
554
+ content = message.get("content", "")
555
+ text = normalize_message_content(content)
556
+ if role in {"system", "developer"}:
557
+ if text:
558
+ instructions.append(text)
559
+ continue
560
+ response_input.append({"role": role, "content": text})
561
+
562
+ response_body: dict[str, Any] = {
563
+ "model": body.get("model") or "gpt-5.2",
564
+ "input": response_input,
565
+ "instructions": "\n\n".join(instructions),
566
+ "store": False,
567
+ }
568
+ if body.get("temperature") is not None:
569
+ response_body["temperature"] = body["temperature"]
570
+ if body.get("top_p") is not None:
571
+ response_body["top_p"] = body["top_p"]
572
+ if body.get("reasoning_effort") is not None:
573
+ response_body["reasoning"] = {"effort": body["reasoning_effort"]}
574
+ return response_body
575
+
576
+
577
+def normalize_message_content(content: Any) -> str:
578
+ if isinstance(content, str):
579
+ return content
580
+ if isinstance(content, list):
581
+ parts: list[str] = []
582
+ for item in content:
583
+ if isinstance(item, dict):
584
+ text = item.get("text")
585
+ if isinstance(text, str):
586
+ parts.append(text)
587
+ elif isinstance(item, str):
588
+ parts.append(item)
589
+ return "\n".join(parts)
590
+ if content is None:
591
+ return ""
592
+ return str(content)
593
+
594
+
595
+def response_text(response: dict[str, Any]) -> str:
596
+ value = response.get("output_text")
597
+ if isinstance(value, str):
598
+ return value
599
+
600
+ pieces: list[str] = []
601
+ output = response.get("output")
602
+ if isinstance(output, list):
603
+ for item in output:
604
+ if not isinstance(item, dict):
605
+ continue
606
+ content = item.get("content")
607
+ if isinstance(content, list):
608
+ for block in content:
609
+ if isinstance(block, dict):
610
+ text = block.get("text")
611
+ if isinstance(text, str):
612
+ pieces.append(text)
613
+ return "".join(pieces)
614
+
615
+
616
+def build_upstream_url(path: str, base_url: str) -> str:
617
+ if path.startswith("http://") or path.startswith("https://"):
618
+ parsed = urlparse(path)
619
+ path = parsed.path
620
+ if parsed.query:
621
+ path = f"{path}?{parsed.query}"
622
+ if path == "/v1":
623
+ path = "/"
624
+ elif path.startswith("/v1/"):
625
+ path = path[3:]
626
+ return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
627
+
628
+
629
+def sanitize_forward_headers(headers: dict[str, str]) -> dict[str, str]:
630
+ blocked = {
631
+ "authorization",
632
+ "chatgpt-account-id",
633
+ "host",
634
+ "openai-beta",
635
+ "content-length",
636
+ "connection",
637
+ }
638
+ return {
639
+ key: value
640
+ for key, value in headers.items()
641
+ if key.lower() not in blocked and value is not None
642
+ }
643
+
644
+
645
+def response_headers(response: requests.Response) -> dict[str, str]:
646
+ blocked = {
647
+ "connection",
648
+ "content-encoding",
649
+ "content-length",
650
+ "transfer-encoding",
651
+ }
652
+ return {
653
+ key: value
654
+ for key, value in response.headers.items()
655
+ if key.lower() not in blocked
656
+ }
657
+
658
+
659
+def upstream_error_message(response: requests.Response, fallback: str) -> str:
660
+ text = response.text
661
+ if not text:
662
+ return fallback
663
+ try:
664
+ payload = json.loads(text)
665
+ except json.JSONDecodeError:
666
+ return text
667
+ if isinstance(payload, dict):
668
+ detail = payload.get("detail")
669
+ if isinstance(detail, str):
670
+ return detail
671
+ error = payload.get("error")
672
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
673
+ return error["message"]
674
+ if isinstance(error, str):
675
+ return error
676
+ return text
677
+
678
+
679
+def resolve_codex_version() -> str:
680
+ configured = codex_config()["codex_version"]
681
+ if configured:
682
+ return configured
683
+ try:
684
+ result = subprocess.run(
685
+ ["codex", "--version"],
686
+ check=False,
687
+ capture_output=True,
688
+ text=True,
689
+ timeout=2,
690
+ )
691
+ version = _extract_semver(result.stdout) or _extract_semver(result.stderr)
692
+ if version:
693
+ return version
694
+ except Exception:
695
+ pass
696
+ return FALLBACK_CODEX_VERSION
697
+
698
+
699
+def resolve_auth_file_candidates() -> list[Path]:
700
+ cfg = codex_config()
701
+ explicit = cfg["auth_file_path"]
702
+ if explicit:
703
+ return [Path(explicit).expanduser()]
704
+
705
+ candidates: list[Path] = []
706
+ for env_name in ("CHATGPT_LOCAL_HOME", "CODEX_HOME"):
707
+ env_home = os.getenv(env_name)
708
+ if env_home:
709
+ candidates.append(Path(env_home).expanduser() / AUTH_FILENAME)
710
+
711
+ home = Path.home()
712
+ candidates.extend(
713
+ [
714
+ home / ".codex" / AUTH_FILENAME,
715
+ home / ".chatgpt-local" / AUTH_FILENAME,
716
+ Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", AUTH_FILENAME)),
717
+ ]
718
+ )
719
+ return _unique_paths(candidates)
720
+
721
+
722
+def resolve_auth_write_path() -> Path:
723
+ for candidate in resolve_auth_file_candidates():
724
+ if candidate.is_file():
725
+ return candidate
726
+ return resolve_auth_file_candidates()[-1]
727
+
728
+
729
+def read_auth_file() -> tuple[Path, dict[str, Any]]:
730
+ candidates = resolve_auth_file_candidates()
731
+ for candidate in candidates:
732
+ try:
733
+ with candidate.open("r", encoding="utf-8") as handle:
734
+ payload = json.load(handle)
735
+ if isinstance(payload, dict):
736
+ return candidate, payload
737
+ except FileNotFoundError:
738
+ continue
739
+ except Exception:
740
+ continue
741
+ return resolve_auth_write_path(), {}
742
+
743
+
744
+def write_auth_file(path: Path, data: dict[str, Any]) -> None:
745
+ path.parent.mkdir(parents=True, exist_ok=True)
746
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
747
+ try:
748
+ path.chmod(0o600)
749
+ except OSError:
750
+ pass
751
+
752
+
753
+def parse_jwt_claims(token: str) -> dict[str, Any]:
754
+ if not token or token.count(".") != 2:
755
+ return {}
756
+ try:
757
+ payload = token.split(".")[1]
758
+ padding = "=" * ((4 - len(payload) % 4) % 4)
759
+ decoded = base64.urlsafe_b64decode((payload + padding).encode("ascii"))
760
+ value = json.loads(decoded)
761
+ return value if isinstance(value, dict) else {}
762
+ except Exception:
763
+ return {}
764
+
765
+
766
+def derive_account_id(id_token: str) -> str:
767
+ return _string(_auth_claims(parse_jwt_claims(id_token)).get("chatgpt_account_id"))
768
+
769
+
770
+def parse_iso(value: str) -> datetime | None:
771
+ if not value:
772
+ return None
773
+ normalized = value.replace("Z", "+00:00")
774
+ try:
775
+ parsed = datetime.fromisoformat(normalized)
776
+ except ValueError:
777
+ return None
778
+ if parsed.tzinfo is None:
779
+ parsed = parsed.replace(tzinfo=timezone.utc)
780
+ return parsed.astimezone(timezone.utc)
781
+
782
+
783
+def utc_now_iso() -> str:
784
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
785
+
786
+
787
+def _auth_claims(claims: dict[str, Any]) -> dict[str, Any]:
788
+ return _record(claims.get("https://api.openai.com/auth"))
789
+
790
+
791
+def _record(value: Any) -> dict[str, Any]:
792
+ return value if isinstance(value, dict) else {}
793
+
794
+
795
+def _string(value: Any) -> str:
796
+ return value if isinstance(value, str) else ""
797
+
798
+
799
+def _jwt_expiration_iso(claims: dict[str, Any]) -> str:
800
+ exp = claims.get("exp")
801
+ if not isinstance(exp, (int, float)):
802
+ return ""
803
+ return datetime.fromtimestamp(float(exp), tz=timezone.utc).isoformat()
804
+
805
+
806
+def _base64url(data: bytes) -> str:
807
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
808
+
809
+
810
+def _token_error_message(response: requests.Response) -> str:
811
+ try:
812
+ payload = response.json()
813
+ except Exception:
814
+ payload = None
815
+ if isinstance(payload, dict):
816
+ for key in OAUTH_ERROR_KEYS:
817
+ value = payload.get(key)
818
+ if isinstance(value, str) and value:
819
+ return value
820
+ error = payload.get("error")
821
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
822
+ return error["message"]
823
+ if isinstance(error, str):
824
+ return error
825
+ return f"OAuth token endpoint returned status {response.status_code}: {response.text}"
826
+
827
+
828
+def _extract_semver(value: str) -> str:
829
+ import re
830
+
831
+ match = re.search(r"\b\d+\.\d+\.\d+\b", value or "")
832
+ return match.group(0) if match else ""
833
+
834
+
835
+def _safe_int(value: Any, default: int) -> int:
836
+ try:
837
+ return int(value)
838
+ except (TypeError, ValueError):
839
+ return default
840
+
841
+
842
+def _device_expires_at(value: Any) -> float:
843
+ if isinstance(value, str):
844
+ parsed = parse_iso(value)
845
+ if parsed is not None:
846
+ return parsed.timestamp()
847
+ return time.time() + DEVICE_CODE_TIMEOUT_SECONDS
848
+
849
+
850
+def _unique_paths(paths: list[Path]) -> list[Path]:
851
+ result: list[Path] = []
852
+ seen: set[str] = set()
853
+ for path in paths:
854
+ key = str(path)
855
+ if key in seen:
856
+ continue
857
+ seen.add(key)
858
+ result.append(path)
859
+ return result