"""OpenAI Codex OAuth: token store, refresh, quota probe, device-code login. Tokens live in ~/.hermes/auth.json, NOT ~/.codex/: Hermes keeps its own Codex OAuth session separate from the Codex CLI / VS Code extension so one app's refresh-token rotation cannot invalidate the other's session. Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so ``hermes_cli.auth.`` keeps resolving (and monkeypatching) as before. Origin-internal helpers are imported lazily inside each function (no import cycle; patches on ``hermes_cli.auth.`` still intercept). """ from __future__ import annotations import logging import hashlib import json import os import threading import time from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from hermes_cli.auth_constants import ( _decode_jwt_claims, AUTH_LOCK_TIMEOUT_SECONDS, AuthError, CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL, CODEX_OAUTH_USER_AGENT, CODEX_RATE_LIMITED_CODE, DEFAULT_CODEX_BASE_URL, _codex_err, httpx, ) from utils import env_float from typing import TYPE_CHECKING if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle from hermes_cli.auth import ProviderConfig # Log-record parity with the origin module (caplog tests pin "hermes_cli.auth"). logger = logging.getLogger("hermes_cli.auth") def _parse_retry_after_seconds(headers: Any) -> Optional[int]: """Best-effort parse of a ``Retry-After`` header into whole seconds.""" from agent.retry_utils import parse_retry_after_seconds seconds = parse_retry_after_seconds(headers) return None if seconds is None else int(seconds) def _clear_pool_entry_status(entry: Dict[str, Any]) -> None: """Reset a pool entry's cooldown / last-error metadata to healthy.""" from hermes_cli.auth import _POOL_STATUS_FIELDS for status_field in _POOL_STATUS_FIELDS: entry[status_field] = None def _codex_access_token_is_expiring(access_token: Any, skew_seconds: int) -> bool: claims = _decode_jwt_claims(access_token) exp = claims.get("exp") if not isinstance(exp, (int, float)): return False return float(exp) <= (time.time() + max(0, int(skew_seconds))) def _codex_base_url() -> str: return os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL def _codex_runtime_result(api_key: str, *, source: str, last_refresh: Optional[str]) -> Dict[str, Any]: return { "provider": "openai-codex", "base_url": _codex_base_url(), "api_key": api_key, "source": source, "last_refresh": last_refresh, "auth_mode": "chatgpt", } def _load_auth_store_maybe_locked(lock: bool) -> Dict[str, Any]: """Load the auth store, taking the cross-process lock unless the caller already holds it.""" from hermes_cli.auth import _auth_store_lock, _load_auth_store if lock: with _auth_store_lock(): return _load_auth_store() return _load_auth_store() def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: """Read Codex OAuth tokens from Hermes auth store (~/.hermes/auth.json).""" from hermes_cli.auth import _load_provider_state, _nonempty_str auth_store = _load_auth_store_maybe_locked(_lock) state = _load_provider_state(auth_store, "openai-codex") if not state: raise _codex_err( "No Codex credentials stored. Run `hermes auth` to authenticate.", "codex_auth_missing", relogin=True, ) tokens = state.get("tokens") if not isinstance(tokens, dict): raise _codex_err( "Codex auth state is missing tokens. Run `hermes auth` to re-authenticate.", "codex_auth_invalid_shape", relogin=True, ) access_token = tokens.get("access_token") refresh_token = tokens.get("refresh_token") if not _nonempty_str(access_token): raise _codex_err( "Codex auth is missing access_token. Run `hermes auth` to re-authenticate.", "codex_auth_missing_access_token", relogin=True, ) if not _nonempty_str(refresh_token): raise _codex_err( "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", "codex_auth_missing_refresh_token", relogin=True, ) return { "tokens": tokens, "last_refresh": state.get("last_refresh"), } def _sync_codex_pool_entries( auth_store: Dict[str, Any], tokens: Dict[str, str], last_refresh: Optional[str], previous_singleton_tokens: Optional[Dict[str, str]] = None, ) -> None: """Mirror a fresh Codex re-auth into the credential_pool OAuth entries. * ``device_code`` — the singleton-seeded entry written by the device-code OAuth flow when the user logged in via ``hermes setup`` / the model picker. Always synced with the fresh tokens. * ``manual:device_code`` — entries created by ``hermes auth add openai-codex`` that use the same device-code OAuth mechanism. * ``manual:api_key`` and any other non-device-code manual sources — those are independent credentials (an explicit API key, a different ChatGPT account, etc.) and must not be overwritten by a single re-auth. """ access_token = tokens.get("access_token") if not access_token: return refresh_token = tokens.get("refresh_token") entries = _pool_entries(auth_store, "openai-codex") if entries is None: return # Previous singleton access_token (before this re-auth overwrote it) — # used to distinguish legacy singleton-aliases from independent accounts. # When None or empty, no manual entry can be treated as an alias (which # is the right default for first-ever-save or a freshly initialized # auth.json). prev_at = None if isinstance(previous_singleton_tokens, dict): prev_at = previous_singleton_tokens.get("access_token") or None for entry in entries: if not isinstance(entry, dict): continue source = entry.get("source") if source == "device_code": # Singleton-seeded mirror — always refresh. refresh_this_entry = True elif source == "manual:device_code": # Refresh only if this entry's existing access_token matches the # previous singleton access_token (i.e. it is a true alias of the # singleton from the #33000 workaround era). An entry with its # own distinct token material is an independent account and must # be left alone (#39236). refresh_this_entry = bool( prev_at and entry.get("access_token") == prev_at ) else: # ``manual:api_key`` and any future non-device-code sources. refresh_this_entry = False if not refresh_this_entry: continue entry["access_token"] = access_token if refresh_token: entry["refresh_token"] = refresh_token if last_refresh: entry["last_refresh"] = last_refresh _clear_pool_entry_status(entry) def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None: """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" from hermes_cli.auth import _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _save_provider_state, _utc_now_z if last_refresh is None: last_refresh = _utc_now_z() with _auth_store_lock(): auth_store = _load_auth_store() state = _load_provider_state(auth_store, "openai-codex") or {} # Capture the previous singleton tokens BEFORE overwriting them. The # pool-sync step uses this to distinguish legacy singleton-aliases # (which should be refreshed) from independent accounts that # ``hermes auth add openai-codex`` created (which must not be # overwritten — see #39236). previous_singleton_tokens = state.get("tokens") if isinstance(state.get("tokens"), dict) else None state["tokens"] = tokens state["last_refresh"] = last_refresh state["auth_mode"] = "chatgpt" if label and str(label).strip(): state["label"] = str(label).strip() _save_provider_state(auth_store, "openai-codex", state) _sync_codex_pool_entries( auth_store, tokens, last_refresh, previous_singleton_tokens=previous_singleton_tokens, ) _save_auth_store(auth_store) def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]: """Adopt a valid Codex CLI token pair into Hermes auth, if available.""" from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens imported = _import_codex_cli_tokens() # Require BOTH tokens before adopting: persisting a payload without a # usable refresh_token would only break the next refresh cycle. if not ( imported and str(imported.get("access_token", "") or "").strip() and str(imported.get("refresh_token", "") or "").strip() ): return None logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason) _save_codex_tokens(imported) return dict(imported) def _refresh_payload_access_token( response: "httpx.Response", *, provider: str, invalid_json: Tuple[str, str], invalid_response: Optional[Tuple[str, str]], missing_access: Tuple[str, str], relogin_required: bool = True, invalid_json_relogin: Optional[bool] = None, strict_str: bool = True, ) -> Tuple[Dict[str, Any], str]: """Parse a 200 token-refresh response; return ``(payload, stripped access_token)``. Each ``(message, code)`` pair keeps the provider's historical wording; ``{exc}`` in *invalid_json*'s message is formatted with the JSON error. *strict_str* rejects non-string access tokens; otherwise they are ``str()``-coerced. """ try: payload = response.json() except Exception as exc: raise AuthError( invalid_json[0].format(exc=exc), provider=provider, code=invalid_json[1], relogin_required=( relogin_required if invalid_json_relogin is None else invalid_json_relogin ), ) from exc if not isinstance(payload, dict): if invalid_response is None: payload = {} else: raise AuthError( invalid_response[0], provider=provider, code=invalid_response[1], relogin_required=relogin_required, ) access = payload.get("access_token") if strict_str: access = access.strip() if isinstance(access, str) else "" else: access = str(access or "").strip() if not access: raise AuthError( missing_access[0], provider=provider, code=missing_access[1], relogin_required=relogin_required, ) return payload, access def _codex_http_client(**kwargs: Any) -> "httpx.Client": """Build an ``httpx.Client`` for Codex OAuth/probe endpoints with racing. Same broken-IPv6 failure mode as the chat transport (#13834): a host that advertises AAAA records but blackholes IPv6 makes each serial connect attempt eat the full connect timeout before IPv4 is tried, so token refresh / device login / usage probes time out where the official Codex CLI (which races families per RFC 8305) works. Best-effort: if the racing backend can't be installed (unexpected httpx/httpcore internals, mocked client in tests), the client still works with the default serial connect behavior. """ client = httpx.Client(**kwargs) try: from agent.process_bootstrap import enable_happy_eyeballs_on_client enable_happy_eyeballs_on_client(client) except Exception: pass return client def _codex_quota_exhausted_error(retry_after: Optional[int]) -> AuthError: if retry_after is not None: message = ( f"Codex provider quota exhausted (429); retry after {retry_after}s. " "Credentials are still valid." ) else: message = ( "Codex provider quota exhausted (429). Credentials are still valid; " "retry after the usage limit resets." ) return _codex_err(message, CODEX_RATE_LIMITED_CODE, relogin=False) def _codex_refresh_failure_error(response: "httpx.Response") -> AuthError: """Decode a non-200 Codex token-refresh response into a shaped AuthError.""" from hermes_cli.auth import _nonempty_str code = "codex_refresh_failed" message = f"Codex token refresh failed with status {response.status_code}." relogin_required = False try: err = response.json() if isinstance(err, dict): err_obj = err.get("error") # OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}} if isinstance(err_obj, dict): nested_code = err_obj.get("code") or err_obj.get("type") if _nonempty_str(nested_code): code = nested_code.strip() nested_msg = err_obj.get("message") if _nonempty_str(nested_msg): message = f"Codex token refresh failed: {nested_msg.strip()}" # OAuth spec shape: {"error": "code_str", "error_description": "..."} elif _nonempty_str(err_obj): code = err_obj.strip() err_desc = err.get("error_description") or err.get("message") if _nonempty_str(err_desc): message = f"Codex token refresh failed: {err_desc.strip()}" except Exception: pass if code in {"invalid_grant", "invalid_token", "invalid_request"}: relogin_required = True if code == "refresh_token_reused": message = ( "Codex refresh token was already consumed by another client " "(e.g. Codex CLI or VS Code extension). " "Run `codex` in your terminal to generate fresh tokens, " "then run `hermes auth` to re-authenticate." ) relogin_required = True # A 401/403 from the token endpoint always means the refresh token # is invalid/expired — force relogin even if the body error code # wasn't one of the known strings above. if response.status_code in {401, 403} and not relogin_required: relogin_required = True return _codex_err(message, code, relogin=relogin_required) def refresh_codex_oauth_pure( access_token: str, refresh_token: str, *, timeout_seconds: float = 20.0, ) -> Dict[str, Any]: """Refresh Codex OAuth tokens without mutating Hermes auth state.""" from hermes_cli.auth import _nonempty_str, _utc_now_z del access_token # Access token is only used by callers to decide whether to refresh. if not _nonempty_str(refresh_token): raise _codex_err( "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", "codex_auth_missing_refresh_token", relogin=True, ) timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) with _codex_http_client( timeout=timeout, headers={ "Accept": "application/json", "User-Agent": CODEX_OAUTH_USER_AGENT, }, ) as client: response = client.post( CODEX_OAUTH_TOKEN_URL, headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": CODEX_OAUTH_CLIENT_ID, }, ) if response.status_code == 429: # Upstream rate-limit / usage-quota exhaustion on the token endpoint. # The stored refresh token is still valid here — re-authenticating # cannot lift a quota cap. Classify distinctly from auth failures so # callers surface a "retry later" notice instead of a misleading # "run hermes auth" prompt (see issue #32790). raise _codex_quota_exhausted_error( _parse_retry_after_seconds(getattr(response, "headers", None)) ) if response.status_code != 200: raise _codex_refresh_failure_error(response) refresh_payload, refreshed_access = _refresh_payload_access_token( response, provider="openai-codex", invalid_json=("Codex token refresh returned invalid JSON.", "codex_refresh_invalid_json"), invalid_response=None, missing_access=( "Codex token refresh response was missing access_token.", "codex_refresh_missing_access_token", ), ) updated = { "access_token": refreshed_access, "refresh_token": refresh_token.strip(), "last_refresh": _utc_now_z(), } next_refresh = refresh_payload.get("refresh_token") if _nonempty_str(next_refresh): updated["refresh_token"] = next_refresh.strip() return updated def _refresh_codex_auth_tokens( tokens: Dict[str, str], timeout_seconds: float, ) -> Dict[str, str]: """Refresh Codex access token using the refresh token.""" from hermes_cli.auth import _save_codex_tokens, refresh_codex_oauth_pure try: refreshed = refresh_codex_oauth_pure( str(tokens.get("access_token", "") or ""), str(tokens.get("refresh_token", "") or ""), timeout_seconds=timeout_seconds, ) except AuthError as exc: # Self-heal cross-store refresh_token rotation. Hermes keeps its OWN # Codex OAuth token (per profile + top-level), separate from the Codex # CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when # the Codex CLI (or another Hermes process) rotates the shared token, # this frozen copy's refresh_token goes stale and the refresh fails with # a relogin-required error (invalid_grant / refresh_token_reused / 401). # Before surfacing that as a hard 401 to the turn, adopt the canonical # fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so # idle profiles / desktop sessions recover automatically instead of # 401'ing until a manual re-auth. Transient failures (e.g. 429 quota) # keep relogin_required=False — the stored token is still valid there, so # we never self-heal those and re-raise unchanged. if not getattr(exc, "relogin_required", False): raise imported = _recover_codex_tokens_from_cli( f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}" ) if not imported: raise return imported updated_tokens = dict(tokens) updated_tokens["access_token"] = refreshed["access_token"] updated_tokens["refresh_token"] = refreshed["refresh_token"] _save_codex_tokens(updated_tokens) return updated_tokens def _import_codex_cli_tokens() -> Optional[Dict[str, str]]: """Try to read tokens from ~/.codex/auth.json (Codex CLI shared file). Returns tokens dict if valid and not expired, None otherwise. Does NOT write to the shared file. """ from hermes_cli.auth import _codex_access_token_is_expiring codex_home = os.getenv("CODEX_HOME", "").strip() if not codex_home: codex_home = str(Path.home() / ".codex") auth_path = Path(codex_home).expanduser() / "auth.json" if not auth_path.is_file(): return None try: payload = json.loads(auth_path.read_text(encoding="utf-8-sig")) tokens = payload.get("tokens") if not isinstance(tokens, dict): return None access_token = tokens.get("access_token") refresh_token = tokens.get("refresh_token") if not access_token or not refresh_token: return None # Reject expired tokens — importing stale tokens from ~/.codex/ # that can't be refreshed leaves the user stuck with "Login successful!" # but no working credentials. if _codex_access_token_is_expiring(access_token, 0): logger.debug( "Codex CLI tokens at %s are expired — skipping import.", auth_path, ) return None return dict(tokens) except Exception: return None def resolve_codex_runtime_credentials( *, force_refresh: bool = False, refresh_if_expiring: bool = True, refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, ) -> Dict[str, Any]: """Resolve runtime credentials from Hermes's own Codex token store. Falls back to the credential pool when the singleton (``providers.openai-codex.tokens``) has no usable access_token but the pool (``credential_pool.openai-codex``) does. """ from hermes_cli.auth import _auth_store_lock, _codex_access_token_is_expiring, _probe_codex_quota_restored, _read_codex_tokens read_error: Optional[AuthError] = None try: data = _read_codex_tokens() except AuthError as exc: read_error = exc if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in { "codex_auth_missing_access_token", "codex_auth_missing_refresh_token", "codex_auth_invalid_shape", }: imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error")) if imported: data = {"tokens": imported, "last_refresh": imported.get("last_refresh")} else: data = None else: data = None if data is None: pool_token = _pool_codex_access_token() if pool_token: return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None) pool_rate_limit = _codex_pool_rate_limit_status() if pool_rate_limit: # Before surfacing the persisted cooldown, ask the Codex usage # endpoint whether the quota actually reset early (banked reset # redeemed, plan upgraded, window reset upstream). The persisted # ``last_error_reset_at`` can be days in the future while the # account is already usable again — see issue #43747. stale_token = str(pool_rate_limit.get("access_token") or "").strip() if stale_token and _probe_codex_quota_restored( stale_token, base_url=pool_rate_limit.get("base_url"), ): logger.info( "Codex quota restored upstream — clearing stale pool cooldown(s)." ) clear_codex_pool_quota_cooldowns() pool_token = _pool_codex_access_token() if pool_token: return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None) reset_at = pool_rate_limit.get("reset_at") remaining = ( int(reset_at - time.time()) if isinstance(reset_at, (int, float)) and reset_at > time.time() else None ) raise _codex_quota_exhausted_error(remaining) if read_error is not None: raise read_error raise _codex_err( "No Codex credentials stored. Run `hermes auth` to authenticate.", "codex_auth_missing", relogin=True, ) tokens = dict(data["tokens"]) access_token = str(tokens.get("access_token", "") or "").strip() refresh_timeout_seconds = env_float("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", 20) should_refresh = bool(force_refresh) if (not should_refresh) and refresh_if_expiring: should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds) if should_refresh: # Re-read under lock to avoid racing with other Hermes processes with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)): data = _read_codex_tokens(_lock=False) tokens = dict(data["tokens"]) access_token = str(tokens.get("access_token", "") or "").strip() should_refresh = bool(force_refresh) if (not should_refresh) and refresh_if_expiring: should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds) if should_refresh: tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds) access_token = str(tokens.get("access_token", "") or "").strip() return _codex_runtime_result( access_token, source="hermes-auth-store", last_refresh=data.get("last_refresh"), ) def _is_codex_rate_limit_shaped( code: Any, reason: Any, message: Any, ) -> bool: """True when persisted pool-entry error metadata describes a 429/quota stop.""" reason_l = str(reason or "").lower() message_l = str(message or "").lower() return ( code == 429 or "rate_limit" in reason_l or "usage_limit" in reason_l or "quota" in reason_l or "rate limit" in message_l or "usage limit" in message_l or "quota" in message_l ) # Throttle for the live Codex quota probe below. The probe runs on the hot # credential-selection path while the pool is exhausted, so without a floor a # busy gateway would hammer the usage endpoint on every model/auxiliary call. CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS = 300 # 5 minutes _codex_quota_probe_cache: Dict[str, Tuple[float, Optional[bool]]] = {} _codex_quota_probe_lock = threading.Lock() def _codex_usage_probe_url(base_url: Optional[str]) -> str: """Resolve the Codex usage endpoint for a probe. Mirrors the Codex CLI's PathStyle split: base URLs containing ``/backend-api`` use the ChatGPT ``/wham/usage`` path, everything else ``/api/codex/usage``. Kept local so this low-level auth module does not import the auxiliary account-usage module. """ normalized = str(base_url or "").strip().rstrip("/") if not normalized: normalized = _codex_base_url() if normalized.endswith("/codex"): normalized = normalized[: -len("/codex")] prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex") return prefix + "/usage" def _probe_codex_quota_restored( access_token: Any, *, base_url: Optional[str] = None, min_interval_seconds: float = CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS, ) -> Optional[bool]: """Ask the Codex usage endpoint whether this account's quota is usable again. Probes are throttled per access token (module-local cache) so the hot selection path can fire this freely. """ from hermes_cli.auth import _codex_quota_probe_cache, _nonempty_str token = str(access_token or "").strip() if not token: return None # Real Codex access tokens are JWTs. Refusing to probe non-JWT tokens # avoids pointless network calls for corrupt/placeholder entries (and # keeps hermetic test fixtures with dummy tokens offline). if not _decode_jwt_claims(token): return None cache_key = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16] now = time.monotonic() with _codex_quota_probe_lock: cached = _codex_quota_probe_cache.get(cache_key) if cached is not None and (now - cached[0]) < min_interval_seconds: return cached[1] # Reserve the slot immediately so concurrent selectors don't stampede # the endpoint while this probe is in flight. _codex_quota_probe_cache[cache_key] = (now, None) result: Optional[bool] = None try: headers = { "Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "codex-cli", } # Best-effort ChatGPT-Account-Id from the JWT (the backend requires it # for some account shapes; harmless to omit for others). claims = _decode_jwt_claims(token) account_id = ( claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") if isinstance(claims.get("https://api.openai.com/auth"), dict) else None ) if _nonempty_str(account_id): headers["ChatGPT-Account-Id"] = account_id.strip() with _codex_http_client(timeout=10.0) as client: response = client.get(_codex_usage_probe_url(base_url), headers=headers) if response.status_code == 200: payload = response.json() or {} rate_limit = payload.get("rate_limit") or {} worst_used: Optional[float] = None for key in ("primary_window", "secondary_window"): used = (rate_limit.get(key) or {}).get("used_percent") if isinstance(used, (int, float)): worst_used = max(worst_used or 0.0, float(used)) if worst_used is not None: result = worst_used < 100.0 elif response.status_code == 429: result = False except Exception: logger.debug("Codex quota probe failed", exc_info=True) result = None with _codex_quota_probe_lock: _codex_quota_probe_cache[cache_key] = (now, result) return result def clear_codex_pool_quota_cooldowns(access_token: Optional[str] = None) -> int: """Clear rate-limit cooldowns on persisted openai-codex pool entries. Called after the upstream quota is KNOWN to be restored (a successful ``/usage reset`` redemption, or a positive live probe) so auth.json stops freezing credentials behind a stale ``last_error_reset_at``. When *access_token* is given, only the matching entry is cleared; otherwise every rate-limited entry clears (a redeemed banked reset restores the whole account, and any entry that is genuinely still exhausted just re-freezes with fresh metadata on its next 429). """ from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store cleared = 0 try: with _auth_store_lock(): auth_store = _load_auth_store() entries = _pool_entries(auth_store, "openai-codex") if entries is None: return 0 for entry in entries: if not isinstance(entry, dict): continue if entry.get("last_status") != "exhausted": continue if access_token and str(entry.get("access_token") or "") != access_token: continue if not _is_codex_rate_limit_shaped( entry.get("last_error_code"), entry.get("last_error_reason"), entry.get("last_error_message"), ): continue _clear_pool_entry_status(entry) cleared += 1 if cleared: _save_auth_store(auth_store) except Exception: logger.debug("Failed to clear Codex pool quota cooldowns", exc_info=True) return cleared def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]: """Return metadata for a pool-only Codex credential in quota cooldown.""" from hermes_cli.auth import _auth_store_lock, _load_auth_store, _nonempty_str def _parse_reset_at(value: Any) -> Optional[float]: if value is None or value == "": return None if isinstance(value, (int, float)): numeric = float(value) if numeric <= 0: return None return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric if isinstance(value, str): raw = value.strip() if not raw: return None try: numeric = float(raw) except ValueError: numeric = None if numeric is not None: return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric try: return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() except ValueError: return None return None try: with _auth_store_lock(): auth_store = _load_auth_store() entries = _pool_entries(auth_store, "openai-codex") if entries is None: return None now = time.time() for entry in entries: if not isinstance(entry, dict): continue token = entry.get("access_token") if not _nonempty_str(token): continue if entry.get("last_status") != "exhausted": continue if not _is_codex_rate_limit_shaped( entry.get("last_error_code"), entry.get("last_error_reason"), entry.get("last_error_message"), ): continue reset_at = _parse_reset_at(entry.get("last_error_reset_at")) if reset_at is not None and reset_at <= now: continue return { "label": entry.get("label"), "last_refresh": entry.get("last_refresh"), "reset_at": reset_at, "reason": entry.get("last_error_reason"), "message": entry.get("last_error_message"), "access_token": token.strip(), "base_url": entry.get("base_url"), } except Exception: logger.debug("Codex pool rate-limit lookup failed", exc_info=True) return None def _pool_entries(auth_store: Dict[str, Any], provider_id: str) -> Optional[List[Any]]: """``auth_store["credential_pool"][provider_id]`` when it is a list, else None.""" pool = auth_store.get("credential_pool") entries = pool.get(provider_id) if isinstance(pool, dict) else None return entries if isinstance(entries, list) else None def _pool_codex_access_token() -> str: """Return the most-recent usable access_token from the openai-codex pool. Used as a fallback by ``resolve_codex_runtime_credentials`` when the singleton has no creds. Reads ``credential_pool.openai-codex`` entries directly from auth.json and picks the first non- empty access_token, preferring entries that are not currently in an exhaustion cooldown. """ from hermes_cli.auth import _auth_store_lock, _load_auth_store, _nonempty_str try: with _auth_store_lock(): auth_store = _load_auth_store() entries = _pool_entries(auth_store, "openai-codex") if entries is None: return "" def _entry_usable(entry: Dict[str, Any]) -> bool: if not isinstance(entry, dict): return False token = entry.get("access_token") if not _nonempty_str(token): return False # Skip entries currently in an exhaustion cooldown window. reset_at = entry.get("last_error_reset_at") return not (isinstance(reset_at, (int, float)) and reset_at > time.time()) for entry in entries: if _entry_usable(entry): return str(entry.get("access_token", "")).strip() except Exception: logger.debug("Codex pool fallback lookup failed", exc_info=True) return "" def _login_openai_codex( args, pconfig: ProviderConfig, *, force_new_login: bool = False, ) -> None: """OpenAI Codex login via device code flow. Tokens stored in ~/.hermes/auth.json.""" from hermes_cli.auth import _codex_access_token_is_expiring, _codex_device_code_login, _import_codex_cli_tokens, _offer_existing_oauth_credentials, _print_login_success, _prompt_yes_no, _save_codex_tokens, _update_config_for_provider, resolve_codex_runtime_credentials del args, pconfig # kept for parity with other provider login helpers # Check for existing Hermes-owned credentials if not force_new_login and _offer_existing_oauth_credentials( "openai-codex", resolve=resolve_codex_runtime_credentials, is_expiring=_codex_access_token_is_expiring, display_name="Codex", default_base_url=DEFAULT_CODEX_BASE_URL, expired_notice="Existing Codex credentials are expired. Starting fresh login...", ): return # Check for existing Codex CLI tokens we can import if not force_new_login: cli_tokens = _import_codex_cli_tokens() if cli_tokens: print("Found existing Codex CLI credentials at ~/.codex/auth.json") print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.") if _prompt_yes_no( "Import these credentials? (a separate login is recommended) [y/N]: ", default="n", ): _save_codex_tokens(cli_tokens) config_path = _update_config_for_provider("openai-codex", _codex_base_url()) print() print("Credentials imported. Note: if Codex CLI refreshes its token,") print("Hermes will keep working independently with its own session.") print(f" Config updated: {config_path} (model.provider=openai-codex)") return # Run a fresh device code flow — Hermes gets its own OAuth session print() print("Signing in to OpenAI Codex...") print("(Hermes creates its own session — won't affect Codex CLI or VS Code)") print() creds = _codex_device_code_login() # Save tokens to Hermes auth store _save_codex_tokens(creds["tokens"], creds.get("last_refresh")) config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL)) _print_login_success("openai-codex", config_path, show_auth_state=True) def _codex_login_rate_limited_error(response: "httpx.Response", *, during: str = "") -> AuthError: """AuthError for a 429 from OpenAI's device-auth endpoints (a throttle, not a credential fault).""" retry_after = _parse_retry_after_seconds(getattr(response, "headers", None)) wait_hint = ( f" Try again in about {retry_after}s." if retry_after is not None else " Wait a minute and run the login again." ) return _codex_err( f"OpenAI is rate-limiting Codex login requests (HTTP 429){during}. " "This is a temporary throttle on OpenAI's side, not a credential " f"problem.{wait_hint}", CODEX_RATE_LIMITED_CODE, ) def _codex_request_device_code(issuer: str, client_id: str) -> Dict[str, Any]: """Step 1 of the Codex device flow: request a user code, retrying capped on HTTP 429.""" # OpenAI's auth endpoint rate-limits this request (HTTP 429) when login is # attempted too often from the same IP/account — retry with capped backoff # (honoring ``Retry-After``) before surfacing a clear, actionable message. resp = None max_attempts = 4 for attempt in range(1, max_attempts + 1): try: with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: resp = client.post( f"{issuer}/api/accounts/deviceauth/usercode", json={"client_id": client_id}, headers={"Content-Type": "application/json"}, ) except Exception as exc: raise _codex_err(f"Failed to request device code: {exc}", "device_code_request_failed") if resp.status_code != 429: break if attempt < max_attempts: retry_after = _parse_retry_after_seconds( getattr(resp, "headers", None) ) # Exponential backoff (2s, 4s, 8s) capped, preferring the # server-provided Retry-After when present. delay = retry_after if retry_after is not None else 2 ** attempt delay = max(1, min(int(delay), 60)) print( "OpenAI is rate-limiting login requests " f"(429); retrying in {delay}s..." ) time.sleep(delay) if resp is not None and resp.status_code == 429: raise _codex_login_rate_limited_error(resp) if resp is None or resp.status_code != 200: status = resp.status_code if resp is not None else "unknown" raise _codex_err( f"Device code request returned status {status}.", "device_code_request_error", ) device_data = resp.json() device_data["interval"] = max(3, int(device_data.get("interval", "5"))) if not device_data.get("user_code", "") or not device_data.get("device_auth_id", ""): raise _codex_err("Device code response missing required fields.", "device_code_incomplete") return device_data def _codex_poll_authorization_code( issuer: str, *, device_auth_id: str, user_code: str, poll_interval: int, ) -> Dict[str, Any]: """Step 3 of the Codex device flow: poll until sign-in completes (403/404 = still pending).""" max_wait = 15 * 60 # 15 minutes start = time.monotonic() code_resp = None try: with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: while time.monotonic() - start < max_wait: time.sleep(poll_interval) poll_resp = client.post( f"{issuer}/api/accounts/deviceauth/token", json={"device_auth_id": device_auth_id, "user_code": user_code}, headers={"Content-Type": "application/json"}, ) if poll_resp.status_code == 200: code_resp = poll_resp.json() break elif poll_resp.status_code in {403, 404}: continue # User hasn't completed login yet else: raise _codex_err( f"Device auth polling returned status {poll_resp.status_code}.", "device_code_poll_error", ) except KeyboardInterrupt: print("\nLogin cancelled.") raise SystemExit(130) if code_resp is None: raise _codex_err("Login timed out after 15 minutes.", "device_code_timeout") return code_resp def _codex_exchange_authorization_code( issuer: str, client_id: str, code_resp: Dict[str, Any], ) -> Dict[str, Any]: """Step 4 of the Codex device flow: swap the authorization code for tokens.""" authorization_code = code_resp.get("authorization_code", "") code_verifier = code_resp.get("code_verifier", "") redirect_uri = f"{issuer}/deviceauth/callback" if not authorization_code or not code_verifier: raise _codex_err( "Device auth response missing authorization_code or code_verifier.", "device_code_incomplete_exchange", ) try: with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: token_resp = client.post( CODEX_OAUTH_TOKEN_URL, data={ "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": redirect_uri, "client_id": client_id, "code_verifier": code_verifier, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) except Exception as exc: raise _codex_err(f"Token exchange failed: {exc}", "token_exchange_failed") if token_resp.status_code == 429: raise _codex_login_rate_limited_error(token_resp, during=" during token exchange") if token_resp.status_code != 200: raise _codex_err( f"Token exchange returned status {token_resp.status_code}.", "token_exchange_error", ) tokens = token_resp.json() if not tokens.get("access_token", ""): raise _codex_err( "Token exchange did not return an access_token.", "token_exchange_no_access_token", ) return tokens def _codex_device_code_login() -> Dict[str, Any]: """Run the OpenAI device code login flow and return credentials dict.""" from hermes_cli.auth import _utc_now_z issuer = "https://auth.openai.com" client_id = CODEX_OAUTH_CLIENT_ID device_data = _codex_request_device_code(issuer, client_id) user_code = device_data["user_code"] device_auth_id = device_data["device_auth_id"] poll_interval = device_data["interval"] # Step 2: Show user the code print("To continue, follow these steps:\n") print(" 1. Open this URL in your browser:") print(f" \033[94m{issuer}/codex/device\033[0m\n") print(" 2. Enter this code:") print(f" \033[94m{user_code}\033[0m\n") print("Waiting for sign-in... (press Ctrl+C to cancel)") code_resp = _codex_poll_authorization_code( issuer, device_auth_id=device_auth_id, user_code=user_code, poll_interval=poll_interval, ) tokens = _codex_exchange_authorization_code(issuer, client_id, code_resp) # Return tokens for the caller to persist (no longer writes to ~/.codex/) return { "tokens": { "access_token": tokens.get("access_token", ""), "refresh_token": tokens.get("refresh_token", ""), }, "base_url": _codex_base_url(), "last_refresh": _utc_now_z(), "auth_mode": "chatgpt", "source": "device-code", }