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