main
py 1,699 lines 54.1 KB
Raw
1 from __future__ import annotations
2
3 import base64
4 import errno
5 import hashlib
6 import json
7 import os
8 import secrets
9 import stat
10 import subprocess
11 import threading
12 import time
13 import uuid
14 from contextlib import contextmanager
15 from dataclasses import dataclass
16 from datetime import datetime, timedelta, timezone
17 from pathlib import Path
18 from typing import Any, BinaryIO, Iterable, Iterator, Mapping
19 from urllib.parse import parse_qs, urlencode, urljoin, urlparse
20
21 import requests
22
23 from helpers import files
24 from plugins._oauth.helpers.config import codex_config
25
26 try:
27 import fcntl
28 except ImportError:
29 fcntl = None
30
31 try:
32 import msvcrt
33 except ImportError:
34 msvcrt = None
35
36
37 AUTH_FILENAME = "auth.json"
38 INSTALLATION_ID_FILENAME = "installation_id"
39 ACCESS_EXPIRY_MARGIN = timedelta(minutes=5)
40 REFRESH_INTERVAL = timedelta(minutes=55)
41 DEFAULT_CODEX_MODEL = "gpt-5.5"
42 CODEX_ORIGINATOR = "codex_cli_rs"
43 CLIENT_METADATA_INSTALLATION_ID = "x-codex-installation-id"
44 CLIENT_METADATA_WINDOW_ID = "x-codex-window-id"
45 CLIENT_METADATA_KEYS = (
46 "slug",
47 "id",
48 "display_name",
49 "description",
50 "visibility",
51 "supported_in_api",
52 "default_reasoning_level",
53 "supported_reasoning_levels",
54 "additional_speed_tiers",
55 "service_tiers",
56 "context_window",
57 "max_context_window",
58 "priority",
59 )
60 OAUTH_ERROR_KEYS = ("error_description", "error")
61 DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
62 WINDOWS_LOCK_RETRY_SECONDS = 0.05
63 USAGE_ENDPOINT_PATHS = (
64 "/backend-api/codex/usage",
65 "/backend-api/wham/usage",
66 "/api/codex/usage",
67 )
68 _AUTH_THREAD_LOCK = threading.RLock()
69
70
71 @dataclass(frozen=True)
72 class PkcePair:
73 verifier: str
74 challenge: str
75
76
77 @dataclass(frozen=True)
78 class EffectiveAuth:
79 access_token: str
80 account_id: str
81 id_token: str = ""
82 refresh_token: str = ""
83 source_path: str = ""
84 last_refresh: str = ""
85
86
87 def generate_pkce() -> PkcePair:
88 verifier = _base64url(secrets.token_bytes(64))
89 challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
90 return PkcePair(verifier=verifier, challenge=challenge)
91
92
93 def generate_state() -> str:
94 return _base64url(secrets.token_bytes(32))
95
96
97 def build_authorize_url(redirect_uri: str, state: str, pkce: PkcePair) -> str:
98 cfg = codex_config()
99 query = {
100 "response_type": "code",
101 "client_id": cfg["client_id"],
102 "redirect_uri": redirect_uri,
103 "scope": " ".join(cfg["scopes"]),
104 "code_challenge": pkce.challenge,
105 "code_challenge_method": "S256",
106 "id_token_add_organizations": "true",
107 "codex_cli_simplified_flow": "true",
108 "state": state,
109 "originator": "codex_cli_rs",
110 }
111 if cfg["forced_workspace_id"]:
112 query["allowed_workspace_id"] = cfg["forced_workspace_id"]
113
114 return f'{cfg["issuer"]}/oauth/authorize?{urlencode(query)}'
115
116
117 def exchange_code_for_tokens(
118 code: str,
119 redirect_uri: str,
120 verifier: str,
121 ) -> dict[str, str]:
122 cfg = codex_config()
123 response = requests.post(
124 cfg["token_url"],
125 headers={"Content-Type": "application/x-www-form-urlencoded"},
126 data={
127 "grant_type": "authorization_code",
128 "code": code,
129 "redirect_uri": redirect_uri,
130 "client_id": cfg["client_id"],
131 "code_verifier": verifier,
132 },
133 timeout=30,
134 )
135 if not response.ok:
136 raise RuntimeError(_token_error_message(response))
137
138 payload = response.json()
139 if not isinstance(payload, dict):
140 raise RuntimeError("OAuth token endpoint returned a malformed response.")
141
142 tokens = {
143 "id_token": str(payload.get("id_token") or ""),
144 "access_token": str(payload.get("access_token") or ""),
145 "refresh_token": str(payload.get("refresh_token") or ""),
146 }
147 missing = [key for key, value in tokens.items() if not value]
148 if missing:
149 raise RuntimeError(f"OAuth token response is missing: {', '.join(missing)}")
150
151 return tokens
152
153
154 def obtain_api_key(id_token: str) -> str:
155 cfg = codex_config()
156 response = requests.post(
157 cfg["token_url"],
158 headers={"Content-Type": "application/x-www-form-urlencoded"},
159 data={
160 "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
161 "client_id": cfg["client_id"],
162 "requested_token": "openai-api-key",
163 "subject_token": id_token,
164 "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
165 },
166 timeout=30,
167 )
168 if not response.ok:
169 raise RuntimeError(f"API-key token exchange failed with status {response.status_code}.")
170 payload = response.json()
171 if not isinstance(payload, dict) or not payload.get("access_token"):
172 raise RuntimeError("API-key token exchange returned a malformed response.")
173 return str(payload["access_token"])
174
175
176 def complete_login(code: str, redirect_uri: str, verifier: str) -> EffectiveAuth:
177 tokens = exchange_code_for_tokens(code, redirect_uri, verifier)
178 return persist_exchanged_tokens(tokens)
179
180
181 def persist_exchanged_tokens(tokens: dict[str, str]) -> EffectiveAuth:
182 id_token = tokens["id_token"]
183 account_id = derive_account_id(id_token)
184 if not account_id:
185 raise RuntimeError("OAuth ID token did not include a ChatGPT account id.")
186
187 cfg = codex_config()
188 if cfg["forced_workspace_id"] and account_id != cfg["forced_workspace_id"]:
189 raise RuntimeError(
190 f'Login is restricted to workspace id {cfg["forced_workspace_id"]}.'
191 )
192
193 try:
194 api_key = obtain_api_key(id_token)
195 except Exception:
196 api_key = ""
197
198 auth_data = {
199 "auth_mode": "chatgpt",
200 "OPENAI_API_KEY": api_key or None,
201 "tokens": {
202 "id_token": id_token,
203 "access_token": tokens["access_token"],
204 "refresh_token": tokens["refresh_token"],
205 "account_id": account_id,
206 },
207 "last_refresh": utc_now_iso(),
208 }
209 path = resolve_auth_write_path()
210 write_auth_file(path, auth_data)
211 return load_auth(ensure_fresh=False)
212
213
214 def request_device_code() -> dict[str, Any]:
215 cfg = codex_config()
216 base_url = cfg["issuer"].rstrip("/")
217 response = requests.post(
218 f"{base_url}/api/accounts/deviceauth/usercode",
219 headers={"Content-Type": "application/json"},
220 json={"client_id": cfg["client_id"]},
221 timeout=30,
222 )
223 if not response.ok:
224 raise RuntimeError(_token_error_message(response))
225
226 payload = response.json()
227 if not isinstance(payload, dict):
228 raise RuntimeError("Device authorization returned a malformed response.")
229
230 device_auth_id = _string(payload.get("device_auth_id"))
231 user_code = _string(payload.get("user_code") or payload.get("usercode"))
232 if not device_auth_id or not user_code:
233 raise RuntimeError("Device authorization response did not include a code.")
234
235 interval = _safe_int(payload.get("interval"), 5)
236 expires_at = _device_expires_at(payload.get("expires_at"))
237 return {
238 "device_auth_id": device_auth_id,
239 "user_code": user_code,
240 "interval": interval,
241 "expires_at": expires_at,
242 "verification_url": f"{base_url}/codex/device",
243 }
244
245
246 def poll_device_authorization(device_auth_id: str, user_code: str) -> dict[str, Any]:
247 cfg = codex_config()
248 base_url = cfg["issuer"].rstrip("/")
249 response = requests.post(
250 f"{base_url}/api/accounts/deviceauth/token",
251 headers={"Content-Type": "application/json"},
252 json={"device_auth_id": device_auth_id, "user_code": user_code},
253 timeout=30,
254 )
255
256 if response.status_code in {403, 404}:
257 return {"completed": False}
258 if not response.ok:
259 raise RuntimeError(_token_error_message(response))
260
261 payload = response.json()
262 if not isinstance(payload, dict):
263 raise RuntimeError("Device authorization token response was malformed.")
264 authorization_code = _string(payload.get("authorization_code"))
265 verifier = _string(payload.get("code_verifier"))
266 if not authorization_code or not verifier:
267 raise RuntimeError("Device authorization response was missing token exchange data.")
268
269 tokens = exchange_code_for_tokens(
270 authorization_code,
271 f"{base_url}/deviceauth/callback",
272 verifier,
273 )
274 auth = persist_exchanged_tokens(tokens)
275 return {"completed": True, "account_id": auth.account_id}
276
277
278 def load_auth(*, ensure_fresh: bool = True) -> EffectiveAuth:
279 path = resolve_auth_write_path()
280 with _auth_file_lock(path):
281 data = _read_auth_file_unlocked(path)
282 tokens = data.get("tokens") if isinstance(data, dict) else {}
283 tokens = tokens if isinstance(tokens, dict) else {}
284
285 access_token = _string(tokens.get("access_token"))
286 id_token = _string(tokens.get("id_token"))
287 refresh_token = _string(tokens.get("refresh_token"))
288 account_id = _string(tokens.get("account_id")) or derive_account_id(id_token)
289 last_refresh = _string(data.get("last_refresh")) if isinstance(data, dict) else ""
290
291 if ensure_fresh and refresh_token and should_refresh(access_token, last_refresh):
292 refreshed = refresh_tokens(refresh_token)
293 access_token = refreshed.get("access_token") or access_token
294 id_token = refreshed.get("id_token") or id_token
295 refresh_token = refreshed.get("refresh_token") or refresh_token
296 account_id = derive_account_id(id_token) or account_id
297 last_refresh = utc_now_iso()
298 data["tokens"] = {
299 "id_token": id_token,
300 "access_token": access_token,
301 "refresh_token": refresh_token,
302 "account_id": account_id,
303 }
304 data["last_refresh"] = last_refresh
305 _write_auth_file_unlocked(path, data)
306
307 if not access_token:
308 raise RuntimeError("Codex/ChatGPT account access token not found. Connect the account first.")
309 if not account_id:
310 raise RuntimeError("Codex/ChatGPT account id not found. Connect the account again.")
311
312 return EffectiveAuth(
313 access_token=access_token,
314 account_id=account_id,
315 id_token=id_token,
316 refresh_token=refresh_token,
317 source_path=str(path),
318 last_refresh=last_refresh,
319 )
320
321
322 def status() -> dict[str, Any]:
323 try:
324 path = resolve_auth_write_path()
325 except Exception as exc:
326 return {
327 "connected": False,
328 "auth_file_path": "",
329 "discovered_auth_files": [],
330 "message": str(exc),
331 }
332 existing = [str(path)] if path.is_file() else []
333 result: dict[str, Any] = {
334 "connected": False,
335 "auth_file_path": str(path),
336 "discovered_auth_files": existing,
337 }
338 try:
339 auth = load_auth(ensure_fresh=False)
340 except Exception as exc:
341 result["message"] = str(exc)
342 return result
343
344 id_claims = parse_jwt_claims(auth.id_token)
345 access_claims = parse_jwt_claims(auth.access_token)
346 auth_claims = _auth_claims(id_claims)
347 result.update(
348 {
349 "connected": True,
350 "auth_file_path": auth.source_path,
351 "account_id": auth.account_id,
352 "email": id_claims.get("email")
353 or _record(id_claims.get("https://api.openai.com/profile")).get("email"),
354 "plan_type": auth_claims.get("chatgpt_plan_type"),
355 "user_id": auth_claims.get("chatgpt_user_id") or auth_claims.get("user_id"),
356 "access_expires_at": _jwt_expiration_iso(access_claims),
357 "last_refresh": auth.last_refresh,
358 }
359 )
360 try:
361 result["usage"] = fetch_usage()
362 except Exception as exc:
363 result["usage"] = {"available": False, "error": str(exc)}
364 return result
365
366
367 def disconnect_auth() -> dict[str, Any]:
368 cleared_paths: list[str] = []
369 removed_paths: list[str] = []
370 preserved_paths: list[str] = []
371
372 path = resolve_auth_write_path()
373 with _auth_file_lock(path):
374 if not path.is_file():
375 return {
376 "disconnected": False,
377 "cleared_auth_files": [],
378 "removed_auth_files": [],
379 "preserved_auth_files": [],
380 }
381 data = _read_auth_file_unlocked(path)
382 if not isinstance(data, dict) or not _contains_chatgpt_auth(data):
383 return {
384 "disconnected": False,
385 "cleared_auth_files": [],
386 "removed_auth_files": [],
387 "preserved_auth_files": [],
388 }
389
390 cleaned = dict(data)
391 cleaned.pop("tokens", None)
392 cleaned.pop("last_refresh", None)
393 if _string(cleaned.get("auth_mode")).lower() == "chatgpt":
394 cleaned.pop("auth_mode", None)
395
396 cleared_paths.append(str(path))
397 if _has_meaningful_auth_data(cleaned):
398 _write_auth_file_unlocked(path, cleaned)
399 preserved_paths.append(str(path))
400 else:
401 path.unlink(missing_ok=True)
402 removed_paths.append(str(path))
403
404 return {
405 "disconnected": bool(cleared_paths),
406 "cleared_auth_files": cleared_paths,
407 "removed_auth_files": removed_paths,
408 "preserved_auth_files": preserved_paths,
409 }
410
411
412 def fetch_usage() -> dict[str, Any]:
413 cfg = codex_config()
414 auth = load_auth()
415 errors: list[str] = []
416 headers = {
417 "Authorization": f"Bearer {auth.access_token}",
418 "ChatGPT-Account-Id": auth.account_id,
419 "Accept": "application/json",
420 "User-Agent": "codex-cli",
421 }
422
423 for url in usage_endpoint_candidates(cfg["upstream_base_url"]):
424 try:
425 response = requests.get(
426 url,
427 headers=headers,
428 timeout=max(5, min(cfg["request_timeout_seconds"], 30)),
429 )
430 except Exception as exc:
431 errors.append(str(exc))
432 continue
433
434 if not response.ok:
435 errors.append(upstream_error_message(response, "Failed to load Codex usage."))
436 continue
437
438 try:
439 payload = response.json()
440 except Exception:
441 payload = {}
442 usage = normalize_usage_payload(payload, response.headers)
443 if usage["available"]:
444 usage["endpoint_path"] = urlparse(url).path
445 return usage
446 errors.append("Usage endpoint returned no rate-limit data.")
447
448 suffix = f" {' '.join(errors[-2:])}" if errors else ""
449 raise RuntimeError(f"Failed to load Codex usage.{suffix}")
450
451
452 def usage_endpoint_candidates(upstream_base_url: str) -> list[str]:
453 parsed = urlparse(upstream_base_url)
454 if not parsed.scheme or not parsed.netloc:
455 return []
456
457 root = f"{parsed.scheme}://{parsed.netloc}"
458 paths = list(USAGE_ENDPOINT_PATHS)
459 upstream_path = parsed.path.rstrip("/")
460 if upstream_path and upstream_path.endswith("/codex"):
461 paths.insert(0, f"{upstream_path}/usage")
462
463 result: list[str] = []
464 seen: set[str] = set()
465 for path in paths:
466 url = urljoin(root.rstrip("/") + "/", path.lstrip("/"))
467 if url in seen:
468 continue
469 seen.add(url)
470 result.append(url)
471 return result
472
473
474 def normalize_usage_payload(
475 payload: Mapping[str, Any] | None,
476 headers: Mapping[str, Any] | None = None,
477 ) -> dict[str, Any]:
478 body = payload if isinstance(payload, Mapping) else {}
479 rate_limit = _record(body.get("rate_limit")) or _record(body.get("rateLimits"))
480 header_usage = _normalize_usage_headers(headers or {})
481
482 primary = (
483 _normalize_usage_window(rate_limit.get("primary_window"))
484 or _normalize_usage_window(rate_limit.get("primary"))
485 or _normalize_usage_window(body.get("primary_window"))
486 or header_usage.get("primary")
487 )
488 secondary = (
489 _normalize_usage_window(rate_limit.get("secondary_window"))
490 or _normalize_usage_window(rate_limit.get("secondary"))
491 or _normalize_usage_window(body.get("secondary_window"))
492 or header_usage.get("secondary")
493 )
494 code_review = _normalize_code_review_usage(body.get("code_review_rate_limit"))
495 additional = _normalize_additional_rate_limits(rate_limit.get("additional_rate_limits"))
496 credits = _normalize_credits(body.get("credits"))
497 plan_type = (
498 _string(body.get("plan_type"))
499 or _string(body.get("planType"))
500 or _string(header_usage.get("plan_type"))
501 )
502
503 return {
504 "available": bool(primary or secondary or code_review or additional),
505 "plan_type": plan_type,
506 "primary": primary,
507 "secondary": secondary,
508 "code_review": code_review,
509 "additional": additional,
510 "credits": credits,
511 "rate_limit_reached_type": _string(
512 rate_limit.get("rate_limit_reached_type")
513 or rate_limit.get("rateLimitReachedType")
514 or body.get("rate_limit_reached_type")
515 or body.get("rateLimitReachedType")
516 ),
517 }
518
519
520 def refresh_tokens(refresh_token: str) -> dict[str, str]:
521 cfg = codex_config()
522 response = requests.post(
523 cfg["token_url"],
524 headers={
525 "Content-Type": "application/json",
526 "User-Agent": resolve_agent_zero_user_agent(),
527 },
528 json={
529 "client_id": cfg["client_id"],
530 "grant_type": "refresh_token",
531 "refresh_token": refresh_token,
532 },
533 timeout=30,
534 )
535 if not response.ok:
536 raise RuntimeError(_token_error_message(response))
537
538 payload = response.json()
539 if not isinstance(payload, dict):
540 raise RuntimeError("OAuth refresh endpoint returned a malformed response.")
541
542 return {
543 "id_token": _string(payload.get("id_token")),
544 "access_token": _string(payload.get("access_token")),
545 "refresh_token": _string(payload.get("refresh_token")) or refresh_token,
546 }
547
548
549 def resolve_agent_zero_user_agent() -> str:
550 try:
551 from helpers import git
552
553 version = git.get_version()
554 except Exception:
555 version = "unknown"
556 return f"agent-zero/{version or 'unknown'}"
557
558
559 def should_refresh(access_token: str, last_refresh: str) -> bool:
560 if not access_token:
561 return True
562
563 claims = parse_jwt_claims(access_token)
564 exp = claims.get("exp")
565 if isinstance(exp, (int, float)):
566 expires_at = datetime.fromtimestamp(float(exp), tz=timezone.utc)
567 if expires_at <= datetime.now(timezone.utc) + ACCESS_EXPIRY_MARGIN:
568 return True
569
570 refreshed_at = parse_iso(last_refresh)
571 if refreshed_at is not None:
572 return refreshed_at <= datetime.now(timezone.utc) - REFRESH_INTERVAL
573 return False
574
575
576 def request_codex(
577 path: str,
578 *,
579 method: str = "GET",
580 headers: dict[str, str] | None = None,
581 body: bytes | str | None = None,
582 stream: bool = False,
583 params: dict[str, str] | None = None,
584 ) -> requests.Response:
585 cfg = codex_config()
586 auth = load_auth()
587 target = build_upstream_url(path, cfg["upstream_base_url"])
588 request_headers = sanitize_forward_headers(headers or {})
589 metadata = client_metadata_from_body(body) or build_client_metadata()
590 request_headers.update(
591 {
592 "Authorization": f"Bearer {auth.access_token}",
593 "chatgpt-account-id": auth.account_id,
594 "OpenAI-Beta": "responses=experimental",
595 "originator": CODEX_ORIGINATOR,
596 CLIENT_METADATA_INSTALLATION_ID: metadata[CLIENT_METADATA_INSTALLATION_ID],
597 CLIENT_METADATA_WINDOW_ID: metadata[CLIENT_METADATA_WINDOW_ID],
598 "session-id": metadata["session_id"],
599 "thread-id": metadata["thread_id"],
600 }
601 )
602 client_version = resolve_codex_version()
603 if client_version:
604 request_headers["version"] = client_version
605
606 return requests.request(
607 method,
608 target,
609 headers=request_headers,
610 data=body,
611 params=params,
612 timeout=max(5, cfg["request_timeout_seconds"]),
613 stream=stream,
614 )
615
616
617 def fetch_model_catalog() -> list[dict[str, Any]]:
618 cfg = codex_config()
619 configured = cfg["models"]
620 if configured:
621 return [
622 {"slug": model, "id": model, "display_name": model}
623 for model in configured
624 ]
625
626 client_version = resolve_codex_version()
627 params = {"client_version": client_version} if client_version else None
628 response = request_codex(
629 "/models",
630 params=params,
631 )
632 if not response.ok:
633 raise RuntimeError(upstream_error_message(response, "Failed to load Codex models."))
634
635 payload = response.json()
636 raw_models = payload.get("models") if isinstance(payload, dict) else None
637 if raw_models is None and isinstance(payload, dict):
638 raw_models = payload.get("data")
639 if not isinstance(raw_models, list):
640 raise RuntimeError("Codex returned a malformed models response.")
641
642 catalog: list[dict[str, Any]] = []
643 seen: set[str] = set()
644 for item in raw_models:
645 if isinstance(item, dict):
646 slug = _string(item.get("slug") or item.get("id"))
647 model = {
648 key: item[key]
649 for key in CLIENT_METADATA_KEYS
650 if key in item
651 }
652 else:
653 slug = _string(item)
654 model = {}
655 if slug and slug not in seen:
656 seen.add(slug)
657 model["slug"] = slug
658 model.setdefault("id", slug)
659 model.setdefault("display_name", slug)
660 catalog.append(model)
661 if not catalog:
662 raise RuntimeError("Codex returned an empty models list.")
663 return catalog
664
665
666 def fetch_models() -> list[str]:
667 return [model["slug"] for model in fetch_model_catalog()]
668
669
670 def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]:
671 normalized = dict(body)
672 settings = codex_config()
673 tools = normalized.get("tools")
674 if isinstance(tools, list):
675 normalized["tools"] = [
676 {
677 **tool,
678 "strict": True,
679 "parameters": {
680 "type": "object",
681 "properties": {"text": {"type": "string"}},
682 "required": ["text"],
683 "additionalProperties": False,
684 },
685 }
686 if isinstance(tool, dict)
687 and tool.get("type") == "function"
688 and tool.get("name") == "response"
689 else tool
690 for tool in tools
691 ]
692 reasoning_effort = normalized.pop("reasoning_effort", None)
693 reasoning = normalized.get("reasoning")
694 if isinstance(reasoning, dict):
695 reasoning = dict(reasoning)
696 elif "reasoning" not in normalized:
697 reasoning = {}
698 effort = reasoning_effort or settings.get("reasoning_effort", "high")
699 if effort != "default":
700 reasoning["effort"] = effort
701 else:
702 reasoning = None
703 if reasoning is not None:
704 summary = settings.get("reasoning_summary", "auto")
705 if summary != "off":
706 reasoning.setdefault("summary", summary)
707 if reasoning:
708 normalized["reasoning"] = reasoning
709 else:
710 normalized.pop("reasoning", None)
711
712 verbosity = normalized.pop("verbosity", None) or settings.get(
713 "text_verbosity", "medium"
714 )
715 text_config = normalized.get("text")
716 if isinstance(text_config, dict):
717 text_config = dict(text_config)
718 if verbosity != "default":
719 text_config.setdefault("verbosity", verbosity)
720 normalized["text"] = text_config
721 elif "text" not in normalized and verbosity != "default":
722 normalized["text"] = {"verbosity": verbosity}
723 input_value = normalized.get("input")
724 if isinstance(input_value, str):
725 normalized["input"] = (
726 [{"role": "user", "content": input_value}] if input_value else []
727 )
728 elif not isinstance(input_value, list):
729 normalized["input"] = []
730 normalized.setdefault("instructions", "")
731 normalized.setdefault("store", False)
732 normalized["client_metadata"] = merge_client_metadata(normalized.get("client_metadata"))
733 if force_stream:
734 normalized["stream"] = True
735 if isinstance(normalized.get("reasoning"), dict):
736 include = normalized.get("include")
737 values = list(include) if isinstance(include, list) else []
738 if "reasoning.encrypted_content" not in values:
739 values.append("reasoning.encrypted_content")
740 normalized["include"] = values
741 normalized.pop("max_output_tokens", None)
742 return normalized
743
744
745 def build_client_metadata() -> dict[str, str]:
746 request_id = f"agent-zero-{uuid.uuid4()}"
747 return {
748 CLIENT_METADATA_INSTALLATION_ID: resolve_installation_id(),
749 "session_id": request_id,
750 "thread_id": request_id,
751 CLIENT_METADATA_WINDOW_ID: "agent-zero",
752 }
753
754
755 def merge_client_metadata(value: Any) -> dict[str, str]:
756 metadata = {
757 str(key): str(item)
758 for key, item in (value.items() if isinstance(value, dict) else [])
759 if item is not None and str(item)
760 }
761 metadata.update(build_client_metadata())
762 return metadata
763
764
765 def client_metadata_from_body(body: bytes | str | None) -> dict[str, str] | None:
766 if body is None:
767 return None
768 try:
769 text = body.decode("utf-8") if isinstance(body, bytes) else body
770 payload = json.loads(text)
771 except Exception:
772 return None
773 if not isinstance(payload, dict):
774 return None
775 metadata = payload.get("client_metadata")
776 if not isinstance(metadata, dict):
777 return None
778 result = {
779 str(key): str(value)
780 for key, value in metadata.items()
781 if value is not None and str(value)
782 }
783 required = {
784 CLIENT_METADATA_INSTALLATION_ID,
785 "session_id",
786 "thread_id",
787 CLIENT_METADATA_WINDOW_ID,
788 }
789 return result if required.issubset(result) else None
790
791
792 def collect_completed_response(response: requests.Response) -> dict[str, Any]:
793 latest_response: dict[str, Any] | None = None
794 latest_error: Any = None
795 text_pieces: list[str] = []
796 latest_usage: dict[str, Any] | None = None
797 completed_items: dict[int, dict[str, Any]] = {}
798 for event in iter_sse_events(response):
799 data = event.get("data")
800 if not data:
801 continue
802 try:
803 parsed = json.loads(data)
804 except json.JSONDecodeError:
805 continue
806 if not isinstance(parsed, dict):
807 continue
808 if event.get("event") == "error":
809 latest_error = parsed
810 continue
811 text_pieces.extend(extract_sse_text_deltas(parsed, event.get("event", "")))
812 if (parsed.get("type") or event.get("event")) == "response.output_item.done":
813 output_index = parsed.get("output_index")
814 item = parsed.get("item")
815 if isinstance(output_index, int) and isinstance(item, dict):
816 completed_items[output_index] = item
817 usage = parsed.get("usage")
818 if isinstance(usage, dict):
819 latest_usage = usage
820 candidate = parsed.get("response")
821 if isinstance(candidate, dict):
822 latest_response = candidate
823
824 if (
825 latest_response is not None
826 and completed_items
827 and not latest_response.get("output")
828 ):
829 latest_response = dict(latest_response)
830 latest_response["output"] = [
831 completed_items[index] for index in sorted(completed_items)
832 ]
833
834 if text_pieces:
835 text = "".join(text_pieces)
836 if latest_response is not None:
837 completed = dict(latest_response)
838 if not response_text(completed):
839 completed["output_text"] = text
840 return completed
841 result: dict[str, Any] = {"output_text": "".join(text_pieces)}
842 if latest_usage:
843 result["usage"] = latest_usage
844 return result
845 if latest_response is not None:
846 return latest_response
847 suffix = f" Last error: {json.dumps(latest_error)}" if latest_error else ""
848 raise RuntimeError(f"No completed response found in Codex SSE stream.{suffix}")
849
850
851 def iter_sse_events(response: requests.Response) -> Iterable[dict[str, str]]:
852 buffer = ""
853 for chunk in response.iter_content(chunk_size=8192, decode_unicode=True):
854 if not chunk:
855 continue
856 if isinstance(chunk, bytes):
857 chunk = chunk.decode(response.encoding or "utf-8", errors="replace")
858 buffer += chunk
859 while "\n\n" in buffer or "\r\n\r\n" in buffer:
860 sep = "\r\n\r\n" if "\r\n\r\n" in buffer else "\n\n"
861 block, buffer = buffer.split(sep, 1)
862 event = parse_sse_block(block)
863 if event:
864 yield event
865 event = parse_sse_block(buffer)
866 if event:
867 yield event
868
869
870 def parse_sse_block(block: str) -> dict[str, str]:
871 event: dict[str, str] = {}
872 data_lines: list[str] = []
873 for line in block.splitlines():
874 if line.startswith("event:"):
875 event["event"] = line[6:].strip()
876 elif line.startswith("data:"):
877 data_lines.append(line[5:].lstrip())
878 if data_lines:
879 event["data"] = "\n".join(data_lines)
880 return event
881
882
883 def extract_sse_text_deltas(payload: dict[str, Any], event_type: str = "") -> list[str]:
884 pieces: list[str] = []
885
886 choices = payload.get("choices")
887 if isinstance(choices, list):
888 for choice in choices:
889 if not isinstance(choice, dict):
890 continue
891 delta = choice.get("delta")
892 if isinstance(delta, dict):
893 _append_text_value(pieces, delta.get("content"))
894 elif isinstance(delta, str):
895 pieces.append(delta)
896
897 message = choice.get("message")
898 if isinstance(message, dict):
899 _append_text_value(pieces, message.get("content"))
900
901 delta = payload.get("delta")
902 if isinstance(delta, str):
903 pieces.append(delta)
904 elif isinstance(delta, dict):
905 _append_text_value(pieces, delta.get("content"))
906 _append_text_value(pieces, delta.get("text"))
907
908 if (payload.get("type") or event_type) in {
909 "response.output_text.delta",
910 "response.text.delta",
911 }:
912 _append_text_value(pieces, payload.get("text"))
913
914 return [piece for piece in pieces if piece]
915
916
917 def _append_text_value(pieces: list[str], value: Any) -> None:
918 if isinstance(value, str):
919 pieces.append(value)
920 return
921 if isinstance(value, list):
922 for item in value:
923 if isinstance(item, str):
924 pieces.append(item)
925 elif isinstance(item, dict):
926 _append_text_value(pieces, item.get("text"))
927 _append_text_value(pieces, item.get("content"))
928
929
930 def chat_messages_to_response_body(body: dict[str, Any]) -> dict[str, Any]:
931 messages = body.get("messages")
932 if not isinstance(messages, list):
933 raise RuntimeError("`messages` must be an array.")
934 if body.get("tools"):
935 raise RuntimeError("Codex/ChatGPT account wrapper does not yet support tool calls.")
936
937 instructions: list[str] = []
938 response_input: list[dict[str, Any]] = []
939 for message in messages:
940 if not isinstance(message, dict):
941 continue
942 role = str(message.get("role") or "user")
943 content = message.get("content", "")
944 if role in {"system", "developer"}:
945 text = normalize_message_content(content)
946 if text:
947 instructions.append(text)
948 continue
949 response_input.append(
950 {"role": role, "content": response_message_content(content)}
951 )
952
953 response_body: dict[str, Any] = {
954 "model": body.get("model") or DEFAULT_CODEX_MODEL,
955 "input": response_input,
956 "instructions": "\n\n".join(instructions),
957 "store": False,
958 }
959 if body.get("temperature") is not None:
960 response_body["temperature"] = body["temperature"]
961 if body.get("top_p") is not None:
962 response_body["top_p"] = body["top_p"]
963 if body.get("reasoning_effort") is not None:
964 response_body["reasoning"] = {"effort": body["reasoning_effort"]}
965 return response_body
966
967
968 def normalize_message_content(content: Any) -> str:
969 if isinstance(content, str):
970 return content
971 if isinstance(content, list):
972 parts: list[str] = []
973 for item in content:
974 if isinstance(item, dict):
975 text = item.get("text")
976 if isinstance(text, str):
977 parts.append(text)
978 elif isinstance(item, str):
979 parts.append(item)
980 return "\n".join(parts)
981 if content is None:
982 return ""
983 return str(content)
984
985
986 def response_message_content(content: Any) -> str | list[dict[str, Any]]:
987 if not isinstance(content, list):
988 return normalize_message_content(content)
989
990 converted: list[dict[str, Any]] = []
991 has_media = False
992 for item in content:
993 if isinstance(item, str):
994 if item:
995 converted.append({"type": "input_text", "text": item})
996 continue
997 if not isinstance(item, dict):
998 continue
999
1000 item_type = str(item.get("type") or "").strip()
1001 if item_type == "text":
1002 text = item.get("text")
1003 if isinstance(text, str) and text:
1004 converted.append({"type": "input_text", "text": text})
1005 continue
1006 if item_type == "input_text":
1007 text = item.get("text")
1008 if isinstance(text, str) and text:
1009 converted.append({"type": "input_text", "text": text})
1010 continue
1011 if item_type == "image_url":
1012 image_url = item.get("image_url")
1013 url = ""
1014 detail = item.get("detail")
1015 if isinstance(image_url, dict):
1016 url = str(image_url.get("url") or "").strip()
1017 detail = image_url.get("detail", detail)
1018 elif isinstance(image_url, str):
1019 url = image_url.strip()
1020 if url:
1021 converted.append(
1022 {
1023 "type": "input_image",
1024 "image_url": url,
1025 "detail": str(detail or "auto"),
1026 }
1027 )
1028 has_media = True
1029 continue
1030 if item_type == "input_image":
1031 image_url = item.get("image_url")
1032 file_id = item.get("file_id")
1033 image: dict[str, Any] = {"type": "input_image"}
1034 if isinstance(image_url, str) and image_url.strip():
1035 image["image_url"] = image_url.strip()
1036 if isinstance(file_id, str) and file_id.strip():
1037 image["file_id"] = file_id.strip()
1038 if "image_url" in image or "file_id" in image:
1039 image["detail"] = str(item.get("detail") or "auto")
1040 converted.append(image)
1041 has_media = True
1042 continue
1043
1044 text = item.get("text")
1045 if isinstance(text, str) and text:
1046 converted.append({"type": "input_text", "text": text})
1047 continue
1048 nested_content = item.get("content")
1049 if isinstance(nested_content, str) and nested_content:
1050 converted.append({"type": "input_text", "text": nested_content})
1051
1052 if has_media:
1053 return converted
1054 return "\n".join(
1055 part["text"]
1056 for part in converted
1057 if part.get("type") == "input_text" and isinstance(part.get("text"), str)
1058 )
1059
1060
1061 def response_text(response: dict[str, Any]) -> str:
1062 value = response.get("output_text")
1063 if isinstance(value, str):
1064 return value
1065
1066 pieces: list[str] = []
1067 output = response.get("output")
1068 if isinstance(output, list):
1069 for item in output:
1070 if not isinstance(item, dict):
1071 continue
1072 content = item.get("content")
1073 if isinstance(content, list):
1074 for block in content:
1075 if isinstance(block, dict):
1076 text = block.get("text")
1077 if isinstance(text, str):
1078 pieces.append(text)
1079 return "".join(pieces)
1080
1081
1082 def build_upstream_url(path: str, base_url: str) -> str:
1083 if path.startswith("http://") or path.startswith("https://"):
1084 parsed = urlparse(path)
1085 path = parsed.path
1086 if parsed.query:
1087 path = f"{path}?{parsed.query}"
1088 if path == "/v1":
1089 path = "/"
1090 elif path.startswith("/v1/"):
1091 path = path[3:]
1092 return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
1093
1094
1095 def sanitize_forward_headers(headers: dict[str, str]) -> dict[str, str]:
1096 blocked = {
1097 "authorization",
1098 "chatgpt-account-id",
1099 "host",
1100 "openai-beta",
1101 "content-length",
1102 "connection",
1103 }
1104 return {
1105 key: value
1106 for key, value in headers.items()
1107 if key.lower() not in blocked and value is not None
1108 }
1109
1110
1111 def response_headers(response: requests.Response) -> dict[str, str]:
1112 blocked = {
1113 "connection",
1114 "content-encoding",
1115 "content-length",
1116 "transfer-encoding",
1117 }
1118 return {
1119 key: value
1120 for key, value in response.headers.items()
1121 if key.lower() not in blocked
1122 }
1123
1124
1125 def upstream_error_message(response: requests.Response, fallback: str) -> str:
1126 text = response.text
1127 if not text:
1128 return fallback
1129 try:
1130 payload = json.loads(text)
1131 except json.JSONDecodeError:
1132 return text
1133 if isinstance(payload, dict):
1134 detail = payload.get("detail")
1135 if isinstance(detail, str):
1136 return detail
1137 error = payload.get("error")
1138 if isinstance(error, dict) and isinstance(error.get("message"), str):
1139 return error["message"]
1140 if isinstance(error, str):
1141 return error
1142 return text
1143
1144
1145 def resolve_codex_version() -> str:
1146 configured = codex_config()["codex_version"]
1147 if configured:
1148 return configured
1149 try:
1150 result = subprocess.run(
1151 ["codex", "--version"],
1152 check=False,
1153 capture_output=True,
1154 text=True,
1155 timeout=2,
1156 )
1157 version = _extract_semver(result.stdout) or _extract_semver(result.stderr)
1158 if version:
1159 return version
1160 except Exception:
1161 pass
1162 return ""
1163
1164
1165 def resolve_installation_id() -> str:
1166 for path in installation_id_candidates():
1167 try:
1168 value = path.read_text(encoding="utf-8").strip()
1169 except OSError:
1170 continue
1171 if value:
1172 return value
1173
1174 value = str(uuid.uuid4())
1175 path = plugin_installation_id_path()
1176 try:
1177 path.parent.mkdir(parents=True, exist_ok=True)
1178 path.write_text(value, encoding="utf-8")
1179 os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
1180 except OSError:
1181 pass
1182 return value
1183
1184
1185 def installation_id_candidates() -> list[Path]:
1186 candidates = [plugin_installation_id_path()]
1187 codex_home = os.environ.get("CODEX_HOME")
1188 if codex_home:
1189 candidates.append(Path(codex_home).expanduser() / INSTALLATION_ID_FILENAME)
1190 candidates.append(Path.home() / ".codex" / INSTALLATION_ID_FILENAME)
1191 return candidates
1192
1193
1194 def plugin_installation_id_path() -> Path:
1195 return Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", INSTALLATION_ID_FILENAME))
1196
1197
1198 def resolve_auth_file_candidates() -> list[Path]:
1199 return [resolve_auth_write_path()]
1200
1201
1202 def resolve_auth_write_path() -> Path:
1203 explicit = codex_config()["auth_file_path"]
1204 path = (
1205 Path(explicit).expanduser()
1206 if explicit
1207 else Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", AUTH_FILENAME))
1208 )
1209 return _validate_private_auth_path(path)
1210
1211
1212 def read_auth_file() -> tuple[Path, dict[str, Any]]:
1213 path = resolve_auth_write_path()
1214 with _auth_file_lock(path):
1215 return path, _read_auth_file_unlocked(path)
1216
1217
1218 def write_auth_file(path: Path, data: dict[str, Any]) -> None:
1219 with _auth_file_lock(path):
1220 _write_auth_file_unlocked(path, data)
1221
1222
1223 @contextmanager
1224 def _auth_file_lock(path: Path) -> Iterator[None]:
1225 lock_path = _auth_lock_path(path)
1226 lock_path.parent.mkdir(parents=True, exist_ok=True)
1227 with _AUTH_THREAD_LOCK:
1228 with lock_path.open("a+b") as handle:
1229 _lock_file(handle)
1230 try:
1231 yield
1232 finally:
1233 _unlock_file(handle)
1234
1235
1236 def _lock_file(handle: BinaryIO) -> None:
1237 if fcntl is not None:
1238 fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
1239 return
1240 if msvcrt is not None:
1241 handle.seek(0, os.SEEK_END)
1242 if handle.tell() == 0:
1243 handle.write(b"\0")
1244 handle.flush()
1245 handle.seek(0)
1246 while True:
1247 try:
1248 msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
1249 return
1250 except OSError as exc:
1251 if exc.errno not in {errno.EACCES, errno.EDEADLK}:
1252 raise
1253 time.sleep(WINDOWS_LOCK_RETRY_SECONDS)
1254 handle.seek(0)
1255 raise RuntimeError("This platform does not support locking the Agent Zero OAuth auth file.")
1256
1257
1258 def _unlock_file(handle: BinaryIO) -> None:
1259 if fcntl is not None:
1260 fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
1261 return
1262 if msvcrt is not None:
1263 handle.seek(0)
1264 msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
1265
1266
1267 def _read_auth_file_unlocked(path: Path) -> dict[str, Any]:
1268 try:
1269 with path.open("r", encoding="utf-8") as handle:
1270 payload = json.load(handle)
1271 return payload if isinstance(payload, dict) else {}
1272 except FileNotFoundError:
1273 return {}
1274 except Exception:
1275 return {}
1276
1277
1278 def _write_auth_file_unlocked(path: Path, data: dict[str, Any]) -> None:
1279 path.parent.mkdir(parents=True, exist_ok=True)
1280 temporary_path = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp")
1281 try:
1282 try:
1283 descriptor = os.open(temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
1284 except OSError as exc:
1285 if exc.errno not in {errno.EACCES, errno.EROFS}:
1286 raise
1287 _write_auth_file_in_place(path, data)
1288 return
1289 with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
1290 handle.write(json.dumps(data, indent=2) + "\n")
1291 handle.flush()
1292 os.fsync(handle.fileno())
1293 try:
1294 os.replace(temporary_path, path)
1295 except OSError as exc:
1296 if exc.errno != errno.EBUSY:
1297 raise
1298 # Linux rejects replacement when a supported custom auth path is a file bind mount.
1299 _write_auth_file_in_place(path, data)
1300 try:
1301 path.chmod(0o600)
1302 except OSError:
1303 pass
1304 finally:
1305 temporary_path.unlink(missing_ok=True)
1306
1307
1308 def _write_auth_file_in_place(path: Path, data: dict[str, Any]) -> None:
1309 with path.open("w", encoding="utf-8") as handle:
1310 handle.write(json.dumps(data, indent=2) + "\n")
1311 handle.flush()
1312 os.fsync(handle.fileno())
1313 try:
1314 path.chmod(0o600)
1315 except OSError:
1316 pass
1317
1318
1319 def _validate_private_auth_path(path: Path) -> Path:
1320 resolved_path = path.expanduser().resolve(strict=False)
1321 for candidate in _known_codex_auth_paths():
1322 if _path_key(resolved_path) == _path_key(candidate) or _same_existing_file(
1323 resolved_path, candidate
1324 ):
1325 raise _private_auth_path_error()
1326 try:
1327 if resolved_path.stat().st_nlink > 1:
1328 raise _private_auth_path_error()
1329 except FileNotFoundError:
1330 pass
1331 return resolved_path
1332
1333
1334 def _private_auth_path_error() -> RuntimeError:
1335 return RuntimeError(
1336 "Agent Zero OAuth credentials must use an Agent Zero-owned auth file. "
1337 "Choose a private auth_file_path or leave it empty for the default private store."
1338 )
1339
1340
1341 def _known_codex_auth_paths() -> list[Path]:
1342 candidates = [
1343 Path.home() / ".codex" / AUTH_FILENAME,
1344 Path.home() / ".chatgpt-local" / AUTH_FILENAME,
1345 ]
1346 for env_name in ("CODEX_HOME", "CHATGPT_LOCAL_HOME"):
1347 env_home = os.getenv(env_name)
1348 if env_home:
1349 candidates.append(Path(env_home).expanduser() / AUTH_FILENAME)
1350 return _unique_paths(candidates)
1351
1352
1353 def _path_key(path: Path) -> str:
1354 return os.path.normcase(str(path.expanduser().resolve(strict=False)))
1355
1356
1357 def _same_existing_file(path: Path, candidate: Path) -> bool:
1358 try:
1359 return path.samefile(candidate)
1360 except OSError:
1361 return False
1362
1363
1364 def _auth_lock_path(path: Path) -> Path:
1365 digest = hashlib.sha256(_path_key(path).encode("utf-8")).hexdigest()
1366 return Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", "locks", f"{digest}.lock"))
1367
1368
1369 def parse_jwt_claims(token: str) -> dict[str, Any]:
1370 if not token or token.count(".") != 2:
1371 return {}
1372 try:
1373 payload = token.split(".")[1]
1374 padding = "=" * ((4 - len(payload) % 4) % 4)
1375 decoded = base64.urlsafe_b64decode((payload + padding).encode("ascii"))
1376 value = json.loads(decoded)
1377 return value if isinstance(value, dict) else {}
1378 except Exception:
1379 return {}
1380
1381
1382 def derive_account_id(id_token: str) -> str:
1383 return _string(_auth_claims(parse_jwt_claims(id_token)).get("chatgpt_account_id"))
1384
1385
1386 def parse_iso(value: str) -> datetime | None:
1387 if not value:
1388 return None
1389 normalized = value.replace("Z", "+00:00")
1390 try:
1391 parsed = datetime.fromisoformat(normalized)
1392 except ValueError:
1393 return None
1394 if parsed.tzinfo is None:
1395 parsed = parsed.replace(tzinfo=timezone.utc)
1396 return parsed.astimezone(timezone.utc)
1397
1398
1399 def utc_now_iso() -> str:
1400 return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
1401
1402
1403 def _auth_claims(claims: dict[str, Any]) -> dict[str, Any]:
1404 return _record(claims.get("https://api.openai.com/auth"))
1405
1406
1407 def _record(value: Any) -> dict[str, Any]:
1408 return value if isinstance(value, dict) else {}
1409
1410
1411 def _string(value: Any) -> str:
1412 return value if isinstance(value, str) else ""
1413
1414
1415 def _jwt_expiration_iso(claims: dict[str, Any]) -> str:
1416 exp = claims.get("exp")
1417 if not isinstance(exp, (int, float)):
1418 return ""
1419 return datetime.fromtimestamp(float(exp), tz=timezone.utc).isoformat()
1420
1421
1422 def _base64url(data: bytes) -> str:
1423 return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
1424
1425
1426 def _token_error_message(response: requests.Response) -> str:
1427 try:
1428 payload = response.json()
1429 except Exception:
1430 payload = None
1431 if isinstance(payload, dict):
1432 for key in OAUTH_ERROR_KEYS:
1433 value = payload.get(key)
1434 if isinstance(value, str) and value:
1435 return value
1436 error = payload.get("error")
1437 if isinstance(error, dict) and isinstance(error.get("message"), str):
1438 return error["message"]
1439 if isinstance(error, str):
1440 return error
1441 return f"OAuth token endpoint returned status {response.status_code}: {response.text}"
1442
1443
1444 def _extract_semver(value: str) -> str:
1445 import re
1446
1447 match = re.search(r"\b\d+\.\d+\.\d+\b", value or "")
1448 return match.group(0) if match else ""
1449
1450
1451 def _safe_int(value: Any, default: int) -> int:
1452 try:
1453 return int(value)
1454 except (TypeError, ValueError):
1455 return default
1456
1457
1458 def _device_expires_at(value: Any) -> float:
1459 if isinstance(value, str):
1460 parsed = parse_iso(value)
1461 if parsed is not None:
1462 return parsed.timestamp()
1463 return time.time() + DEVICE_CODE_TIMEOUT_SECONDS
1464
1465
1466 def _unique_paths(paths: list[Path]) -> list[Path]:
1467 result: list[Path] = []
1468 seen: set[str] = set()
1469 for path in paths:
1470 key = str(path)
1471 if key in seen:
1472 continue
1473 seen.add(key)
1474 result.append(path)
1475 return result
1476
1477
1478 def _contains_chatgpt_auth(data: dict[str, Any]) -> bool:
1479 tokens = _record(data.get("tokens"))
1480 if _string(data.get("auth_mode")).lower() == "chatgpt":
1481 return True
1482 return any(
1483 _string(tokens.get(key))
1484 for key in ("access_token", "refresh_token", "id_token", "account_id")
1485 )
1486
1487
1488 def _has_meaningful_auth_data(data: dict[str, Any]) -> bool:
1489 for value in data.values():
1490 if value is None:
1491 continue
1492 if isinstance(value, str) and not value.strip():
1493 continue
1494 if isinstance(value, (dict, list, tuple, set)) and not value:
1495 continue
1496 return True
1497 return False
1498
1499
1500 def _normalize_usage_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
1501 lowered = {str(key).lower(): value for key, value in headers.items()}
1502 primary = _normalize_usage_window(
1503 {
1504 "used_percent": lowered.get("x-codex-primary-used-percent"),
1505 "window_minutes": lowered.get("x-codex-primary-window-minutes"),
1506 "reset_at": lowered.get("x-codex-primary-resets-at")
1507 or lowered.get("x-codex-primary-reset-at"),
1508 }
1509 )
1510 secondary = _normalize_usage_window(
1511 {
1512 "used_percent": lowered.get("x-codex-secondary-used-percent"),
1513 "window_minutes": lowered.get("x-codex-secondary-window-minutes"),
1514 "reset_at": lowered.get("x-codex-secondary-resets-at")
1515 or lowered.get("x-codex-secondary-reset-at"),
1516 }
1517 )
1518 return {
1519 "primary": primary,
1520 "secondary": secondary,
1521 "plan_type": _string(lowered.get("x-codex-plan-type")),
1522 }
1523
1524
1525 def _normalize_code_review_usage(value: Any) -> dict[str, Any] | None:
1526 data = _record(value)
1527 if not data:
1528 return None
1529 window = (
1530 _normalize_usage_window(data.get("primary_window"))
1531 or _normalize_usage_window(data.get("primary"))
1532 or _normalize_usage_window(data)
1533 )
1534 if window:
1535 window["name"] = _string(data.get("name")) or "Code review"
1536 return window
1537
1538
1539 def _normalize_additional_rate_limits(value: Any) -> list[dict[str, Any]]:
1540 if not isinstance(value, list):
1541 return []
1542 result: list[dict[str, Any]] = []
1543 for item in value:
1544 data = _record(item)
1545 if not data:
1546 continue
1547 window = (
1548 _normalize_usage_window(data.get("primary_window"))
1549 or _normalize_usage_window(data.get("primary"))
1550 or _normalize_usage_window(data)
1551 )
1552 if not window:
1553 continue
1554 name = (
1555 _string(data.get("name"))
1556 or _string(data.get("model"))
1557 or _string(data.get("limit_name"))
1558 or _string(data.get("limitName"))
1559 or _string(data.get("id"))
1560 )
1561 if name:
1562 window["name"] = name
1563 result.append(window)
1564 return result
1565
1566
1567 def _normalize_usage_window(value: Any) -> dict[str, Any] | None:
1568 data = _record(value)
1569 used_percent = _number(
1570 _first_present(
1571 data.get("used_percent"),
1572 data.get("usedPercent"),
1573 data.get("utilization"),
1574 data.get("usage_percent"),
1575 )
1576 )
1577 if used_percent is None:
1578 return None
1579
1580 window_seconds = _number(
1581 _first_present(
1582 data.get("limit_window_seconds"),
1583 data.get("window_seconds"),
1584 data.get("windowSeconds"),
1585 data.get("windowDurationSeconds"),
1586 )
1587 )
1588 window_minutes = _number(
1589 _first_present(
1590 data.get("window_minutes"),
1591 data.get("windowMinutes"),
1592 data.get("windowDurationMins"),
1593 )
1594 )
1595 if window_seconds is None and window_minutes is not None:
1596 window_seconds = window_minutes * 60
1597 if window_minutes is None and window_seconds is not None:
1598 window_minutes = window_seconds / 60
1599
1600 reset_at = _epoch_seconds(
1601 _first_present(
1602 data.get("reset_at"),
1603 data.get("resets_at"),
1604 data.get("resetsAt"),
1605 data.get("resetAt"),
1606 )
1607 )
1608 used = max(0.0, min(100.0, used_percent))
1609 return {
1610 "used_percent": _clean_number(used),
1611 "remaining_percent": _clean_number(max(0.0, 100.0 - used)),
1612 "reset_at": reset_at,
1613 "resets_at_iso": _epoch_iso(reset_at),
1614 "window_seconds": _clean_number(window_seconds) if window_seconds is not None else None,
1615 "window_minutes": _clean_number(window_minutes) if window_minutes is not None else None,
1616 "label": _usage_window_label(window_seconds, window_minutes),
1617 }
1618
1619
1620 def _normalize_credits(value: Any) -> dict[str, Any] | None:
1621 data = _record(value)
1622 if not data:
1623 return None
1624 balance = _number(data.get("balance"))
1625 return {
1626 "has_credits": bool(data.get("has_credits") or data.get("hasCredits")),
1627 "unlimited": bool(data.get("unlimited")),
1628 "balance": _clean_number(balance) if balance is not None else None,
1629 }
1630
1631
1632 def _usage_window_label(seconds: float | None, minutes: float | None) -> str:
1633 if seconds is None and minutes is not None:
1634 seconds = minutes * 60
1635 if seconds is None:
1636 return ""
1637 if 17_940 <= seconds <= 18_060:
1638 return "5h"
1639 if 604_000 <= seconds <= 605_000:
1640 return "7d"
1641 if seconds >= 86_400 and seconds % 86_400 == 0:
1642 return f"{int(seconds // 86_400)}d"
1643 if seconds >= 3_600 and seconds % 3_600 == 0:
1644 return f"{int(seconds // 3_600)}h"
1645 if seconds >= 60 and seconds % 60 == 0:
1646 return f"{int(seconds // 60)}m"
1647 return ""
1648
1649
1650 def _number(value: Any) -> float | None:
1651 if isinstance(value, bool) or value is None:
1652 return None
1653 if isinstance(value, (int, float)):
1654 return float(value)
1655 if isinstance(value, str):
1656 text = value.strip().rstrip("%")
1657 if not text:
1658 return None
1659 try:
1660 return float(text)
1661 except ValueError:
1662 return None
1663 return None
1664
1665
1666 def _first_present(*values: Any) -> Any:
1667 for value in values:
1668 if value is None:
1669 continue
1670 if isinstance(value, str) and value == "":
1671 continue
1672 return value
1673 return None
1674
1675
1676 def _epoch_seconds(value: Any) -> float | None:
1677 number = _number(value)
1678 if number is None or number <= 0:
1679 return None
1680 if number > 1_000_000_000_000:
1681 number = number / 1000
1682 return number
1683
1684
1685 def _epoch_iso(value: float | None) -> str:
1686 if value is None:
1687 return ""
1688 try:
1689 return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
1690 except (OSError, ValueError):
1691 return ""
1692
1693
1694 def _clean_number(value: float | None) -> int | float | None:
1695 if value is None:
1696 return None
1697 if float(value).is_integer():
1698 return int(value)
1699 return round(float(value), 2)