From ac29c79ff26c2fea198198dbbda78e4d509f71d8 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 21 May 2026 19:50:19 -0500 Subject: [PATCH] fix: ai local auth --- backend/app/services/provider_usage.py | 64 +++++++++++++++++-- compose.yml | 6 +- .../src/app/settings/ai-providers/page.tsx | 18 +++--- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/backend/app/services/provider_usage.py b/backend/app/services/provider_usage.py index feecc30..a2049b8 100644 --- a/backend/app/services/provider_usage.py +++ b/backend/app/services/provider_usage.py @@ -645,6 +645,9 @@ _CODEX_SUBSCRIPTION_URL = "https://chatgpt.com/backend-api/wham/usage" # Path to the Claude Code OAuth credentials file, mounted read-only from the host. _CLAUDE_CREDENTIALS_PATH = os.environ.get("CLAUDE_CREDENTIALS_PATH", "") +# Path to the Codex CLI auth file, mounted read-only from the host. +_CODEX_CREDENTIALS_PATH = os.environ.get("CODEX_CREDENTIALS_PATH", "") + _claude_oauth_cache: tuple[float, str] | None = None # (expires_at_ms, access_token) @@ -688,6 +691,55 @@ def _read_claude_local_oauth_token() -> str | None: return access_token + +def _read_codex_local_token() -> str | None: + """Read the Codex CLI JWT access token from the host auth file (~/.codex/auth.json). + + The Codex CLI stores a long-lived JWT (typically ~9 days) issued by OpenAI's + OAuth flow. This token works as a Bearer credential for the ChatGPT backend + API, including the wham/usage subscription endpoint. + + Returns the access_token string, or None if the file is absent, unreadable, + or the token has expired. The caller should fall back to the manually + configured session_key when this returns None. + """ + path = _CODEX_CREDENTIALS_PATH + if not path: + home = os.path.expanduser("~") + path = os.path.join(home, ".codex", "auth.json") + + try: + with open(path) as fh: + data = _json_module.load(fh) + except (FileNotFoundError, PermissionError, ValueError, OSError): + return None + + tokens = data.get("tokens") + if not isinstance(tokens, dict): + return None + + access_token = tokens.get("access_token") + if not isinstance(access_token, str) or not access_token: + return None + + # Decode the JWT payload (no signature verification needed here — we just + # want to avoid using a token we know is expired before making a network call). + parts = access_token.split(".") + if len(parts) >= 2: + import base64 as _base64 + try: + pad = parts[1] + "=" * (4 - len(parts[1]) % 4) + payload = _json_module.loads(_base64.urlsafe_b64decode(pad)) + exp = payload.get("exp") + if isinstance(exp, (int, float)) and exp <= _time_module.time(): + logger.debug("provider_usage.codex_oauth.token_expired exp=%s", exp) + return None + except Exception: + pass # Can't decode JWT — proceed optimistically + + return access_token + + _ANTHROPIC_WINDOW_LABELS: dict[str, str] = { "five_hour": "Current session", "seven_day": "All models", @@ -810,7 +862,7 @@ async def _fetch_codex_subscription(session_key: str) -> tuple[list[Subscription reset_dt = datetime.fromtimestamp(reset_ts, tz=timezone.utc).replace(tzinfo=None) windows.append(SubscriptionWindow( key="primary", - label=f"{window_hours}h", + label=f"Current session ({window_hours}h)", pct_used=round(float(primary["used_percent"]), 1), reset_at=reset_dt, )) @@ -819,7 +871,7 @@ async def _fetch_codex_subscription(session_key: str) -> tuple[list[Subscription if isinstance(secondary, dict) and secondary.get("used_percent") is not None: sec_seconds = secondary.get("limit_window_seconds") or 86400 sec_hours = round(sec_seconds / 3600) - sec_label = "Week" if sec_hours >= 168 else f"{sec_hours}h" + sec_label = "All models (weekly)" if sec_hours >= 168 else f"All models ({sec_hours}h)" reset_ts = secondary.get("reset_at") reset_dt = None if isinstance(reset_ts, (int, float)): @@ -942,9 +994,11 @@ async def fetch_provider_usage( provider=provider, account_key=account_key, checked_at=utcnow(), reachable=True, ) - # Overlay subscription windows when session_key available - if session_key and result.reachable is not False: - sub_windows, plan_label = await _fetch_codex_subscription(session_key) + # Overlay subscription windows — auto-detected Codex CLI token takes precedence + local_codex = _read_codex_local_token() + effective_codex_key = local_codex or session_key + if effective_codex_key and result.reachable is not False: + sub_windows, plan_label = await _fetch_codex_subscription(effective_codex_key) if sub_windows: result.subscription_windows = sub_windows result.subscription_plan = plan_label diff --git a/compose.yml b/compose.yml index cefb48a..6448b09 100644 --- a/compose.yml +++ b/compose.yml @@ -44,11 +44,13 @@ services: LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN} BASE_URL: ${BASE_URL:-http://localhost:8000} RQ_REDIS_URL: redis://redis:6379/0 - # Claude Code credentials — read-only mount lets the backend auto-detect - # the local OAuth token for subscription usage without manual session keys. + # Claude Code credentials — read-only mount for subscription usage auto-detection. CLAUDE_CREDENTIALS_PATH: /run/secrets/claude_credentials + # Codex CLI credentials — read-only mount for ChatGPT subscription usage auto-detection. + CODEX_CREDENTIALS_PATH: /run/secrets/codex_credentials volumes: - ${CLAUDE_CREDENTIALS_FILE:-/home/kaspa/.claude/.credentials.json}:/run/secrets/claude_credentials:ro + - ${CODEX_CREDENTIALS_FILE:-/home/kaspa/.codex/auth.json}:/run/secrets/codex_credentials:ro depends_on: db: condition: service_healthy diff --git a/frontend/src/app/settings/ai-providers/page.tsx b/frontend/src/app/settings/ai-providers/page.tsx index 84bdc77..864ab79 100644 --- a/frontend/src/app/settings/ai-providers/page.tsx +++ b/frontend/src/app/settings/ai-providers/page.tsx @@ -114,17 +114,15 @@ const PROVIDERS: ProviderConfig[] = [ "It is only shown once, so store it in a password manager", ], showSessionKey: true, - sessionKeyLabel: "ChatGPT subscription token (optional — for plan usage %)", - sessionKeyPlaceholder: "eyJhbGc… (Bearer token from chatgpt.com)", + sessionKeyLabel: "Subscription token (optional — auto-detected via Codex CLI)", + sessionKeyPlaceholder: "eyJhbGc… (leave blank to auto-detect)", + autoDetectedNote: + "Subscription usage (current 5h window %, weekly %) is auto-detected from your local Codex CLI login at ~/.codex/auth.json. You only need to fill this in if Pipeline is running on a different machine from the Codex CLI, or the auto-detected token is not working.", sessionKeyHelp: [ - "Sign in to chatgpt.com in Chrome or Firefox", - "Open DevTools: press F12 (Windows/Linux) or Cmd+Option+I (Mac)", - "Click the Network tab, then refresh the page (F5)", - "In the filter box, type backend-api and click any request", - 'In the right panel, choose "Headers" → "Request Headers"', - 'Find the "Authorization" header — its value starts with "Bearer eyJhbG…"', - 'Copy just the token part after the word "Bearer " (do not include "Bearer " itself)', - "Paste it here. Tokens expire when you log out of ChatGPT", + "Install the Codex CLI: npm install -g @openai/codex", + "Run: codex login", + "This stores a JWT at ~/.codex/auth.json — Pipeline reads it automatically", + "If you prefer to paste a token manually: sign in to chatgpt.com, open DevTools (F12), Network tab, filter to backend-api, click any request, find the Authorization header, copy the token after \"Bearer \"", ], }, {