from __future__ import annotations import logging import math import time from dataclasses import dataclass, replace from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Callable, Optional import httpx from agent.anthropic_credentials import _is_oauth_token, resolve_anthropic_token from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials from hermes_cli.auth_codex import _codex_pool_route_base_url from hermes_cli.runtime_provider import resolve_runtime_provider from hermes_time import safe_strftime from agent.usage_gauges import BP_FULL if TYPE_CHECKING: from typing import TypeGuard logger = logging.getLogger(__name__) _DEPLETED_LINE = "Status: access depleted โ€” top up to restore" # Usage-window plans: allowances are shown as percent used. _WINDOW_TOP_TIER_USED_UP = "Monthly large-model usage is used up." _WINDOW_PAUSED_LINE = ("Status: included usage paused. Your subscription payment failed. Update your card to restore it. " "Until then, Hermes uses your credits.") _WINDOW_PAUSED_OTHER_LINE = "Status: included usage paused. See Billing in the Portal. Until then, Hermes uses your credits." def _utc_now() -> datetime: return datetime.now(UTC) @dataclass(frozen=True) class AccountUsageWindow: label: str used_percent: Optional[float] = None reset_at: Optional[datetime] = None detail: Optional[str] = None # ``account``: exhausting this window exhausts the whole login (Codex session/weekly). # ``model``: the window caps only one model family (Anthropic Opus/Sonnet weekly) and can # never imply the account itself is out of quota. scope: str = "account" @dataclass(frozen=True) class AccountUsageSnapshot: provider: str source: str fetched_at: datetime title: str = "Account limits" plan: Optional[str] = None windows: tuple[AccountUsageWindow, ...] = () details: tuple[str, ...] = () unavailable_reason: Optional[str] = None # Stable account identity of the credential the snapshot was fetched with (e.g. a decoded # Codex JWT principal), when the provider supports one. Never a secret; ``None`` = the # fetcher cannot tell accounts apart, so the snapshot belongs to the provider's legacy slot. identity: Optional[str] = None # Exact decoded provider response body (no headers/credentials) for integrations that need # fields Hermes does not normalize yet. Only populated by providers that fetch a JSON body. raw: Optional[dict] = None # Usage-window plans only: the stacked bar layout. Every other snapshot # renders exactly as before. usage_window: bool = False @property def available(self) -> bool: return bool(self.windows or self.details) and not self.unavailable_reason def _snapshot(provider: str, source: str, windows: list, details: list, **kw: Any) -> AccountUsageSnapshot: return AccountUsageSnapshot(provider=provider, source=source, fetched_at=_utc_now(), windows=tuple(windows), details=tuple(details), **kw) def _title_case_slug(value: Optional[str]) -> Optional[str]: cleaned = str(value or "").strip() return cleaned.replace("_", " ").replace("-", " ").title() if cleaned else None def _parse_dt(value: Any) -> Optional[datetime]: if value in {None, ""}: return None if isinstance(value, (int, float)): return datetime.fromtimestamp(float(value), tz=UTC) if not isinstance(value, str) or not (text := value.strip()): return None text = text[:-1] + "+00:00" if text.endswith("Z") else text try: dt = datetime.fromisoformat(text) return dt if dt.tzinfo else dt.replace(tzinfo=UTC) except ValueError: return None def _format_reset(dt: Optional[datetime]) -> str: if not dt: return "unknown" stamp = safe_strftime(dt.astimezone(), "%Y-%m-%d %H:%M %Z") total_seconds = int((dt - _utc_now()).total_seconds()) if total_seconds <= 0: return f"now ({stamp})" hours, rem = divmod(total_seconds, 3600) minutes = rem // 60 if hours >= 24: days, hours = divmod(hours, 24) return f"in {days}d {hours}h ({stamp})" return f"in {hours}h {minutes}m ({stamp})" if hours else f"in {minutes}m ({stamp})" def _reset_phrase(dt: Optional[datetime]) -> str: """``resets in 23h`` โ€” no wall-clock stamp (those wrap and collide in the TUI).""" if not dt: return "resets soon" try: from hermes_cli.model_picker_usage import format_resets_in return format_resets_in(int(dt.timestamp() * 1000)) except Exception: logger.debug("Reset phrase formatting failed", exc_info=True) return f"resets {_format_reset(dt)}" def render_account_usage_lines(snapshot: Optional[AccountUsageSnapshot], *, markdown: bool = False) -> list[str]: if not snapshot: return [] if snapshot.usage_window: return _render_usage_window_lines(snapshot) bold = "**" if markdown else "" plan = f" ({snapshot.plan})" if snapshot.plan else "" lines = [f"๐Ÿ“ˆ {bold}{snapshot.title}{bold}", f"Provider: {snapshot.provider}{plan}"] for window in snapshot.windows: if window.used_percent is None: base = f"{window.label}: unavailable" else: used = float(window.used_percent) base = f"{window.label}: {max(0, round(100 - used))}% remaining ({max(0, round(used))}% used)" if window.reset_at: base += f" โ€ข resets {_format_reset(window.reset_at)}" elif window.detail: base += f" โ€ข {window.detail}" lines.append(base) lines.extend(snapshot.details) if snapshot.unavailable_reason: lines.append(f"Unavailable: {snapshot.unavailable_reason}") return lines def _render_usage_window_lines(snapshot: AccountUsageSnapshot) -> list[str]: """Usage-window plans: plain stacked bar lines for CLI ``/usage`` and the TUI panel. No emoji, no ANSI, no markdown โ€” Ink ```` would otherwise count escape bytes as width and smear the wrap.""" if snapshot.plan: lines = [snapshot.plan] else: lines = [snapshot.title, f"Provider: {snapshot.provider}"] label_w = max((len(window.label) for window in snapshot.windows), default=0) for window in snapshot.windows: label = f"{window.label:<{label_w}}" if label_w else window.label if window.used_percent is None: base = f"{label} unavailable" else: used = max(0.0, min(100.0, float(window.used_percent))) / 100.0 try: from hermes_cli.model_picker_usage import format_percent_used, render_bar bar, pct = render_bar(used), format_percent_used(used) except Exception: logger.debug("Usage bar rendering failed", exc_info=True) bar, pct = "", f"{int(used * 100)}%" base = f"{label} {bar} {pct} used" if bar else f"{label} {pct} used" if window.reset_at: base += f" ยท {_reset_phrase(window.reset_at)}" elif window.detail: base += f" ยท {window.detail}" lines.append(base) lines.extend(snapshot.details) if snapshot.unavailable_reason: lines.append(f"Unavailable: {snapshot.unavailable_reason}") return lines def _fmt_usd(d: float) -> str: return f"${d:,.2f}" def _is_num(v: Any) -> TypeGuard[float]: return isinstance(v, (int, float)) def _is_finite_num(v: Any) -> TypeGuard[float]: """True iff v is a real number (int/float, not bool, not NaN/Inf); TypeGuard so callers can do arithmetic.""" return _is_num(v) and not isinstance(v, bool) and math.isfinite(v) def _nous_snapshot(windows: list, details: list, tail: list, *, source: str, plan: Optional[str] = None, usage_window: bool = False) -> Optional[AccountUsageSnapshot]: """Nous snapshot with *tail* lines appended, or None when there is nothing to show.""" if not windows and not details: return None return _snapshot("nous", source, windows, details + tail, title="Nous credits", plan=plan, usage_window=usage_window) def _bp_window(label: str, used_bp: Any, resets_at_ms: Any) -> Optional[AccountUsageWindow]: """One usage-window gauge row from basis points + an epoch-ms reset (already bounded by ``usage_gauges.parse_gauges``); None when the percent is not an int. An unknown reset keeps the row, shown without a reset.""" if isinstance(used_bp, bool) or not isinstance(used_bp, int): return None known = isinstance(resets_at_ms, int) and not isinstance(resets_at_ms, bool) reset_at = datetime.fromtimestamp(resets_at_ms / 1000, tz=UTC) if known else None return AccountUsageWindow(label=label, used_percent=max(0.0, min(100.0, used_bp / BP_FULL * 100.0)), reset_at=reset_at) def _window_top_tier_line(reset_at: Optional[datetime], keep_going_on_credits: Any = None) -> str: """``Monthly large-model usage is used up. Resets Nov 3. Past this, large models use your credits.`` (local date). With Keep going on credits off, credits don't unlock it, so the line points at the other included models.""" resets = "" if isinstance(reset_at, datetime): local = reset_at.astimezone() resets = f" Resets {local:%b} {local.day}." tail = " Your other included usage is still available." if keep_going_on_credits is False else " Past this, large models use your credits." return f"{_WINDOW_TOP_TIER_USED_UP}{resets}{tail}" _WINDOW_KEEP_GOING_OFF_LINE = "Keep going on credits: off. Hermes waits for the reset (turn on in /topup)." _WINDOW_KEEP_GOING_ON_LINE = "Keep going on credits: on. Uses your credits when included usage runs out." def _usage_window_rows(gauges: Any, *, paused_reason: Any, keep_going_on_credits: Any = None) -> tuple[list[AccountUsageWindow], list[str]]: """One row per server allowance, in the server's order, plus the top-tier, payment-paused and top-up-off lines. Empty unless the list has an allowance that stops every model. ``keep_going_on_credits``: the account setting (None: an older server).""" from agent.usage_gauges import SCOPE_TOP_TIER, all_scope, gauge_title, parse_gauges parsed = parse_gauges(gauges) if not parsed or not all_scope(parsed): return [], [] out: list[AccountUsageWindow] = [] top_row: Optional[AccountUsageWindow] = None top_full = False for g in parsed: row = _bp_window(gauge_title(g), g.used_bp, g.resets_at_ms) if row is None: continue out.append(row) if g.scope == SCOPE_TOP_TIER: top_row, top_full = row, g.full lines: list[str] = [] if top_full: lines.append(_window_top_tier_line(top_row.reset_at if top_row else None, keep_going_on_credits)) if isinstance(paused_reason, str) and paused_reason: from agent.usage_gauges import paused_for_payment lines.append(_WINDOW_PAUSED_LINE if paused_for_payment(paused_reason) else _WINDOW_PAUSED_OTHER_LINE) elif keep_going_on_credits is False: lines.append(_WINDOW_KEEP_GOING_OFF_LINE) elif keep_going_on_credits is True: lines.append(_WINDOW_KEEP_GOING_ON_LINE) return out, lines def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: """NousPortalAccountInfo โ†’ /usage snapshot: dollar magnitudes + renewal date + portal CTA, plus a ``% used`` gauge when the portal supplies ``monthly_credits``. A usage-window account (``usage_window`` present) shows its allowances as percent used, the large-model line and its top-up balance instead. Fail-open โ†’ None.""" try: from hermes_cli.nous_account import nous_portal_topup_url if account_info is None or not getattr(account_info, "logged_in", False): return None access = getattr(account_info, "paid_service_access_info", None) sub = getattr(account_info, "subscription", None) windows: list[AccountUsageWindow] = [] details: list[str] = [] uw = getattr(account_info, "usage_window", None) if uw is not None: windows, window_lines = _usage_window_rows( getattr(uw, "gauges", None), paused_reason=getattr(uw, "paused_reason", None), keep_going_on_credits=getattr(uw, "keep_going_on_credits", None), ) if windows: # usage-window account: percents, plus the top-up balance details.extend(window_lines) topup = getattr(access, "purchased_credits_remaining", None) if access is not None else None if _is_finite_num(topup): details.append(f"Top-up credits: {_fmt_usd(topup)}") if getattr(account_info, "paid_service_access", None) is False and not window_lines: details.append(_DEPLETED_LINE) if sub is not None and (period_end := getattr(sub, "current_period_end", None)): from agent.billing_usage import format_renews details.append(f"Renews: {format_renews(period_end) or period_end}") return _nous_snapshot(windows, details, ["Top up via /topup"], source="portal-account", plan=getattr(sub, "plan", None) if sub is not None else None, usage_window=True) # Gauge needs a positive cap AND a finite remaining <= cap (numeric fields, NOT a server *_usd); used = # cap - remaining clamped [0,100] so debt reads 100%. NaN/Inf (json.loads accepts bare NaN โ†’ "$nan") and # remaining > cap (rollover makes the cap a meaningless denominator) fall back to the magnitudes lines. if sub is not None: cap = getattr(sub, "monthly_credits", None) sub_remaining = getattr(sub, "credits_remaining", None) if _is_finite_num(cap) and cap > 0 and _is_finite_num(sub_remaining) and sub_remaining <= cap: windows.append(AccountUsageWindow( label="Subscription", used_percent=max(0.0, min(100.0, (cap - sub_remaining) / cap * 100.0)), detail=f"{_fmt_usd(sub_remaining)} of {_fmt_usd(cap)} left", )) if access is not None: for attr, label in (("subscription_credits_remaining", "Subscription credits"), ("purchased_credits_remaining", "Top-up credits"), ("total_usable_credits", "Total usable")): value = getattr(access, attr, None) if _is_finite_num(value): details.append(f"{label}: {_fmt_usd(value)}") if sub is not None: rollover = getattr(sub, "rollover_credits", None) if _is_finite_num(rollover) and rollover > 0: details.append(f"Rollover: {_fmt_usd(rollover)}") period_end = getattr(sub, "current_period_end", None) if period_end: details.append(f"Renews: {period_end}") if getattr(account_info, "paid_service_access", None) is False: details.append(_DEPLETED_LINE) return _nous_snapshot(windows, details, [f"Top up: {nous_portal_topup_url(account_info)}", "(or run /topup)"], source="portal-account", plan=getattr(sub, "plan", None) if sub is not None else None) except (AttributeError, TypeError): return None def _nous_logged_in() -> bool: """Cheap local auth-state check: a Nous access token is present. Fail-open False.""" try: from hermes_cli.auth import get_provider_auth_state tok = (get_provider_auth_state("nous") or {}).get("access_token") return isinstance(tok, str) and bool(tok.strip()) except Exception: return False def _fetch_portal_account(timeout: float): """Wall-clock-bounded fresh portal account fetch (raises on any failure/timeout). No ``with`` block on purpose: ``Executor.__exit__`` joins the worker via ``shutdown(wait=True)``, so a portal that accepts the connection but never answers would hold the caller until the provider's own timeout instead of ``timeout``. The abandoned daemon worker runs on to its own network timeout and never blocks the caller or process exit; its eventual exception is drained so GC never logs "exception was never retrieved".""" import contextvars from hermes_cli.nous_account import get_nous_portal_account_info from tools.daemon_pool import DaemonThreadPoolExecutor context = contextvars.copy_context() pool = DaemonThreadPoolExecutor(max_workers=1) future = pool.submit(context.run, get_nous_portal_account_info, force_fresh=True) try: return future.result(timeout=timeout) except BaseException: future.add_done_callback(lambda f: f.exception()) raise finally: pool.shutdown(wait=False) def _header_gauge_percent(bp: Any) -> Optional[float]: if isinstance(bp, bool) or not isinstance(bp, int): return None return max(0.0, min(100.0, bp / BP_FULL * 100.0)) def apply_header_gauge(snapshot: Optional[AccountUsageSnapshot], credits_state: Any) -> Optional[AccountUsageSnapshot]: """Raise window percents to the last response headers when those are ahead of the portal. The portal counter waits on spend-log ingestion. The headers already include finished included calls, and a large-model call that drew top-up reports that allowance full.""" if snapshot is None or not snapshot.usage_window or credits_state is None: return snapshot if not getattr(credits_state, "from_header", False) or not getattr(credits_state, "is_usage_window", False): return snapshot # Row title โ†’ (header used bp, that gauge's reset). A header from before its gauge reset describes the # old window: the portal's fresh number stands. from agent.usage_gauges import gauge_title mapping = {gauge_title(g): (g.used_bp, g.resets_at_ms) for g in getattr(credits_state, "gauges", ()) or ()} now_ms = int(time.time() * 1000) windows: list[AccountUsageWindow] = [] changed = False for window in snapshot.windows: bp, resets_at_ms = mapping.get(window.label, (None, None)) stale = isinstance(resets_at_ms, int) and not isinstance(resets_at_ms, bool) and now_ms >= resets_at_ms header = None if stale else _header_gauge_percent(bp) current = window.used_percent if header is None or current is None or header <= float(current): windows.append(window) continue changed = True windows.append(replace(window, used_percent=header)) if not changed: return snapshot details = snapshot.details top_gauge = getattr(credits_state, "top_tier_gauge", None) top = next((w for w in windows if top_gauge is not None and w.label == gauge_title(top_gauge)), None) if top is not None and (top.used_percent or 0) >= 100: if not any(line.startswith(_WINDOW_TOP_TIER_USED_UP) for line in details): details = details + (_window_top_tier_line(top.reset_at, getattr(credits_state, "keep_going_on_credits", None)),) return replace(snapshot, windows=tuple(windows), details=details) def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0, credits_state: Any = None) -> list[str]: """Rendered Nous-credits /usage lines, or [] when there's nothing to show. Independent of any live agent (logged-in gate, then a bounded portal fetch); shared by CLI ``_show_usage`` and the TUI ``session.usage`` RPC. Fail-open: any hiccup or timeout โ†’ []. HERMES_DEV_CREDITS_FIXTURE renders from the fixture instead of the portal. ``credits_state`` is the last response's header gauge; when it is ahead of the portal, the bars use it.""" return nous_credits_view(markdown=markdown, timeout=timeout, credits_state=credits_state)[0] def nous_credits_view(*, markdown: bool = False, timeout: float = 10.0, credits_state: Any = None) -> tuple[list[str], bool]: """``nous_credits_lines`` plus whether they are the usage-window layout (callers keep today's presentation for everyone else). Fail-open โ†’ ``([], False)``.""" try: from agent.credits_tracker import dev_fixture_credits_state fixture = dev_fixture_credits_state() except Exception: logger.debug("Dev credits fixture unavailable", exc_info=True) fixture = None if fixture is not None: snapshot = _snapshot_from_credits_state(fixture) return render_account_usage_lines(snapshot, markdown=markdown), bool(snapshot and snapshot.usage_window) if not _nous_logged_in(): return [], False try: snapshot = apply_header_gauge( build_nous_credits_snapshot(_fetch_portal_account(timeout)), credits_state, ) return render_account_usage_lines(snapshot, markdown=markdown), bool(snapshot and snapshot.usage_window) except Exception: # Fail-open; breadcrumb so a dead /usage credits block is diagnosable. logger.debug("credits โ–ธ /usage portal fetch/render failed (fail-open)", exc_info=True) return [], False def nous_credits_usage_fields(agent: Any = None) -> dict[str, Any]: """The ``session.usage`` fields for the Nous credits block: ``credits_lines`` (when there are any) and ``credits_usage_window`` for the usage-window layout. Reads *agent*'s live credits headers. Never raises.""" try: lines, is_window = nous_credits_view(credits_state=getattr(agent, "_credits_state", None) if agent else None) except Exception: logger.debug("credits โ–ธ /usage fields unavailable", exc_info=True) return {} return ({"credits_lines": lines} | ({"credits_usage_window": True} if is_window else {})) if lines else {} def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]: """Header-shaped CreditsState (dev fixture) โ†’ /usage snapshot, same shape as the portal path. *_usd strings are display-only; the % comes from CreditsState.used_fraction. Fail-open โ†’ None.""" try: if state is None: return None windows: list[AccountUsageWindow] = [] details: list[str] = [] if getattr(state, "is_usage_window", False): windows, window_lines = _usage_window_rows( state.gauges, paused_reason=state.paused_reason, keep_going_on_credits=getattr(state, "keep_going_on_credits", None), ) details.extend(window_lines) if getattr(state, "purchased_usd", None): details.append(f"Top-up credits: ${state.purchased_usd}") if getattr(state, "paid_access", True) is False and not window_lines: details.append(_DEPLETED_LINE) return _nous_snapshot(windows, details, ["(dev fixture โ€” HERMES_DEV_CREDITS_FIXTURE)"], source="dev-fixture", usage_window=True) uf = getattr(state, "used_fraction", None) sub_usd = getattr(state, "subscription_usd", None) cap_usd = getattr(state, "subscription_limit_usd", None) if _is_num(uf) and math.isfinite(uf): windows.append(AccountUsageWindow( label="Subscription", used_percent=max(0.0, min(100.0, uf * 100.0)), detail=f"${sub_usd} of ${cap_usd} left" if sub_usd and cap_usd else None, )) for value, label in ((sub_usd, "Subscription credits"), (getattr(state, "purchased_usd", None), "Top-up credits"), (getattr(state, "remaining_usd", None), "Total usable")): if value: details.append(f"{label}: ${value}") if getattr(state, "paid_access", True) is False: details.append(_DEPLETED_LINE) return _nous_snapshot(windows, details, ["(dev fixture โ€” HERMES_DEV_CREDITS_FIXTURE)"], source="dev-fixture") except (AttributeError, TypeError): return None @dataclass(frozen=True) class CreditsView: """Surface-agnostic ``/topup`` balance view: one portal fetch, consumed identically by every money surface. Fail-open: not logged in / portal unreachable โ†’ ``logged_in`` False, ``topup_url`` None.""" logged_in: bool balance_lines: tuple[str, ...] = () identity_line: Optional[str] = None topup_url: Optional[str] = None depleted: bool = False def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView: """/topup view: balance block + identity line + top-up URL. Reuses the /usage fetch + snapshot so numbers match; the balance block drops the trailing top-up/hint lines (/topup has its own affordance). Fail-open โ†’ ``CreditsView(logged_in=False)``.""" not_logged_in = CreditsView(logged_in=False) if not _nous_logged_in(): return not_logged_in try: account = _fetch_portal_account(timeout) except Exception: logger.debug("credits โ–ธ /topup portal fetch failed (fail-open)", exc_info=True) return not_logged_in if account is None or not getattr(account, "logged_in", False): return not_logged_in from hermes_cli.nous_account import nous_portal_topup_url balance_lines = [ line for line in render_account_usage_lines(build_nous_credits_snapshot(account), markdown=markdown) if not line.lstrip().startswith(("Top up:", "Top up via", "(or run")) ] who = [str(v) for v in (getattr(account, "email", None),) if v] org_name = getattr(account, "org_name", None) if org_name: who.append(f"org {org_name}") return CreditsView( logged_in=True, balance_lines=tuple(balance_lines), identity_line=("Topping up as " + " / ".join(who)) if who else None, topup_url=nous_portal_topup_url(account), depleted=getattr(account, "paid_service_access", None) is False, ) def _codex_backend_urls(base_url: str) -> tuple[str, str, str]: """Codex backend endpoints (usage, reset-credits list, consume). Mirrors the Codex CLI's PathStyle split: ``/backend-api`` bases use the ChatGPT ``/wham/`` paths; everything else ``/api/codex/``.""" normalized = (base_url or "").strip().rstrip("/") or "https://chatgpt.com/backend-api/codex" normalized = normalized.removesuffix("/codex") prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex") return (prefix + "/usage", prefix + "/rate-limit-reset-credits", prefix + "/rate-limit-reset-credits/consume") def _resolve_codex_usage_credentials( base_url: Optional[str], api_key: Optional[str], *, force_refresh: bool = False, ) -> tuple[str, str, Optional[str]]: """Codex quota credentials: explicit live-agent creds โ†’ native runtime resolver (itself pool-aware) โ†’ direct pool select. Native OAuth stores device-code logins in the pool, so the singleton store alone is not enough.""" explicit_key = str(api_key or "").strip() if explicit_key and not force_refresh: return explicit_key, str(base_url or "").strip(), None if explicit_key: # Forced retry for a live agent's own credential: refresh THAT credential (singleton or the # pool entry that issued it), never re-resolve โ€” that would render another pool account's usage. try: singleton_key = str((_read_codex_tokens().get("tokens") or {}).get("access_token", "") or "").strip() except AuthError: singleton_key = "" if singleton_key != explicit_key: from agent.credential_pool import load_pool entry = load_pool("openai-codex").try_refresh_matching(api_key_hint=explicit_key) if entry is None: raise RuntimeError("Could not refresh the Codex credential this session runs on") return entry.runtime_api_key, _codex_pool_route_base_url(entry.runtime_base_url or base_url), None # Only AuthError is caught so tier 3 can run: a broad except would mask a transient refresh/network failure # and hand back a DIFFERENT pool account's usage; such errors must propagate to the fail-open outer guard. # account_id is best-effort: a partial singleton store must not sink a usable credential. try: # Tier 2: the native runtime resolver. It ALREADY falls back to the credential pool when the # singleton is empty (see ``resolve_codex_runtime_credentials`` โ€” issue #32992), so in a pool-only # setup this returns a usable ``source="credential_pool"`` token. A refresh/network error must # propagate โ€” the outer ``fetch_account_usage`` guard fails open (shows nothing this turn) rather # than reporting the wrong account. resolve_kwargs = {"refresh_if_expiring": True} if force_refresh: resolve_kwargs["force_refresh"] = True creds = resolve_codex_runtime_credentials(**resolve_kwargs) account_id: Optional[str] = None try: tokens = _read_codex_tokens().get("tokens") or {} account_id = str(tokens.get("account_id", "") or "").strip() or None except AuthError: # Pool-only creds carry no singleton account_id; header is optional. logger.debug("codex โ–ธ /usage account_id read failed (best-effort)", exc_info=True) return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id except AuthError: logger.debug("codex โ–ธ /usage runtime resolver returned no creds; trying pool", exc_info=True) # Tier 3: pool credentials have no account_id concept โ†’ header omitted. from agent.credential_pool import load_pool entry = load_pool("openai-codex").select() if entry is None: raise RuntimeError("No available openai-codex credential in credential pool") # Pool rows keep the canonical URL; a gateway key must go to its route host, not chatgpt.com (#121486). return entry.runtime_api_key, _codex_pool_route_base_url(entry.runtime_base_url or base_url), None def _codex_banked_resets(payload: dict) -> int: raw = (payload.get("rate_limit_reset_credits") or {}).get("available_count") return int(raw) if _is_num(raw) else 0 def _codex_headers(token: str, account_id: Optional[str]) -> dict[str, str]: """auth.json's ``account_id`` wins over the JWT claim; the JWT still supplies the residency header.""" from agent.codex_headers import codex_account_headers return {"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "codex-cli", **codex_account_headers(token), **({"ChatGPT-Account-ID": account_id} if account_id else {})} def _get_json(url: str, headers: dict[str, str], *, timeout: float) -> dict: with httpx.Client(timeout=timeout) as client: response = client.get(url, headers=headers) response.raise_for_status() return response.json() or {} def _usage_windows( source: dict, mapping: tuple[tuple[str, str], ...], used_key: str, reset_key: str, *, fraction: bool = False, model_scoped: frozenset[str] | set[str] = frozenset(), ) -> list[AccountUsageWindow]: """Build windows from ``source[key][used_key]``; ``fraction`` scales values <= 1 to percent. ``model_scoped`` keys build windows that cap only a model family, never the account.""" windows: list[AccountUsageWindow] = [] for key, label in mapping: window = source.get(key) or {} used = window.get(used_key) if used is None: continue used = float(used) if fraction and used <= 1: used *= 100 windows.append(AccountUsageWindow( label=label, used_percent=used, reset_at=_parse_dt(window.get(reset_key)), scope="model" if key in model_scoped else "account", )) return windows # Published Codex quota windows by ``limit_window_seconds``: 5h session and 7-day weekly. _CODEX_WINDOW_LABELS_BY_SECONDS = {18000: "Session", 604800: "Weekly"} _CODEX_WINDOW_POSITIONAL_LABELS = (("primary_window", "Session"), ("secondary_window", "Weekly")) def _codex_window_labels(rate_limit: dict) -> tuple[tuple[str, str], ...]: """Label Codex windows by their published duration, not response position (#65387). The usage API keys windows ``primary_window``/``secondary_window`` by position; when only the weekly limit is returned it occupies ``primary_window`` and the positional mapping mislabeled it ``Session``. Windows whose ``limit_window_seconds`` is missing or unrecognized keep the legacy positional label so duration-less payloads render exactly as before. """ labels = [] for key, fallback in _CODEX_WINDOW_POSITIONAL_LABELS: window = rate_limit.get(key) or {} seconds = window.get("limit_window_seconds") if isinstance(window, dict) else None label = _CODEX_WINDOW_LABELS_BY_SECONDS.get(int(seconds), fallback) if _is_num(seconds) else fallback labels.append((key, label)) return tuple(labels) def _plural(count: int) -> str: return "s" if count != 1 else "" def _fetch_codex_account_usage( base_url: Optional[str] = None, api_key: Optional[str] = None, ) -> Optional[AccountUsageSnapshot]: return _fetch_codex_account_usage_impl(base_url, api_key, read_only=False) def _fetch_codex_account_usage_read_only( base_url: Optional[str] = None, api_key: Optional[str] = None, ) -> Optional[AccountUsageSnapshot]: """Picker-cache variant: a 401 is reported, never repaired. The live-agent path (``_fetch_codex_account_usage``) retries via a forced credential refresh because the session it serves owns that credential; a per-credential read of the pool must NOT rotate, refresh or mutate anything โ€” it just marks that account unknown.""" return _fetch_codex_account_usage_impl(base_url, api_key, read_only=True) def _fetch_codex_account_usage_impl( base_url: Optional[str] = None, api_key: Optional[str] = None, *, read_only: bool = False, ) -> Optional[AccountUsageSnapshot]: token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key) try: payload = _get_json( _codex_backend_urls(resolved_base_url)[0], _codex_headers(token, account_id), timeout=15.0, ) except httpx.HTTPStatusError as exc: if exc.response.status_code != 401: raise if read_only: # Never refresh/rotate on the picker's read-only path โ€” surface the failure. raise token, resolved_base_url, account_id = _resolve_codex_usage_credentials( base_url, api_key, force_refresh=True, ) payload = _get_json( _codex_backend_urls(resolved_base_url)[0], _codex_headers(token, account_id), timeout=15.0, ) rate_limit = payload.get("rate_limit") or {} windows = _usage_windows(rate_limit, _codex_window_labels(rate_limit), "used_percent", "reset_at") details: list[str] = [] count = _codex_banked_resets(payload) if count > 0: details.append(f"You have {count} reset{_plural(count)} banked - use /usage reset to activate") credits, balance = payload.get("credits") or {}, (payload.get("credits") or {}).get("balance") if credits.get("has_credits") and _is_num(balance): details.append(f"Credits balance: ${float(balance):.2f}") elif credits.get("has_credits") and credits.get("unlimited"): details.append("Credits balance: unlimited") return _snapshot("openai-codex", "usage_api", windows, details, plan=_title_case_slug(payload.get("plan_type")), identity=_codex_snapshot_identity(token), raw=payload) def _codex_snapshot_identity(token: str) -> Optional[str]: """Trusted per-account identity of a Codex credential: the decoded JWT principal (``chatgpt_account_id`` + ``sub``), never the token itself. Two credentials for one workspace member share it, so pool accounts are counted once, honestly.""" from agent.credential_pool import _codex_principal_identity principal = _codex_principal_identity(token) return f"codex:{principal[0]}:{principal[1]}" if principal else None @dataclass(frozen=True) class CodexResetRedeemResult: """Outcome of a `/usage reset` attempt against the Codex backend.""" status: str # reset|nothing_to_reset|no_credit|already_redeemed|not_exhausted|no_credits_banked|unavailable message: str available_count: int = 0 windows_reset: int = 0 @property def redeemed(self) -> bool: return self.status == "reset" # Client-side guard: a window only counts as exhausted when fully used; below this, redeeming a banked reset # wastes most of its value โ†’ block, point at --force. _CODEX_WINDOW_EXHAUSTED_PERCENT = 100.0 def _unavailable(message: str) -> CodexResetRedeemResult: return CodexResetRedeemResult(status="unavailable", message=message) def _codex_reset_guard(payload: dict, available: int, force: bool) -> Optional[CodexResetRedeemResult]: """Refuse a redemption that would be wasted (no banked credits, or no window fully used and not ``force``).""" if available <= 0: return CodexResetRedeemResult(status="no_credits_banked", message="No banked reset credits on this account โ€” nothing to redeem.") rate_limit = payload.get("rate_limit") or {} used_pcts = [float(u) for u in ((rate_limit.get(k) or {}).get("used_percent") for k in ("primary_window", "secondary_window")) if _is_num(u)] worst_used: Optional[float] = max(0.0, *used_pcts) if used_pcts else None if force or (worst_used is not None and worst_used >= _CODEX_WINDOW_EXHAUSTED_PERCENT): return None usage_note = (f"your busiest window is only {worst_used:.0f}% used" if worst_used is not None else "your current usage could not be confirmed as exhausted") return CodexResetRedeemResult( status="not_exhausted", available_count=available, message=(f"โš ๏ธ Not redeeming: {usage_note}. A banked reset restores your FULL 5h + weekly limits, so spending it " f"now would waste most of it. You have {available} reset{_plural(available)} banked. " f"Use `/usage reset --force` to redeem anyway."), ) def _codex_reset_outcome(body: dict, available: int) -> CodexResetRedeemResult: """Map the consume response ``code`` to a result (``reset`` also lifts persisted pool cooldowns).""" code = str(body.get("code", "") or "").strip().lower() remaining = max(0, available - 1) outcomes: dict[str, tuple[str, int]] = { "reset": (f"โœ… Reset redeemed โ€” your usage limits have been reset. {remaining} banked reset{_plural(remaining)} remaining.", remaining), "nothing_to_reset": ("Backend reports nothing to reset โ€” your limits aren't exhausted. The credit was NOT spent.", available), "no_credit": ("Backend reports no available reset credit on this account.", 0), "already_redeemed": ("This redemption was already processed โ€” no additional credit was spent.", remaining), } if code not in outcomes: return _unavailable(f"Unexpected response from the Codex backend: {body!r}") windows_reset = 0 if code == "reset": # Quota is restored upstream โ€” lift persisted pool cooldowns so the credential isn't frozen behind a # stale ``last_error_reset_at``. try: from hermes_cli.auth import clear_codex_pool_quota_cooldowns clear_codex_pool_quota_cooldowns() except Exception: logger.debug("Failed to clear Codex pool cooldowns after reset redemption", exc_info=True) raw = body.get("windows_reset") windows_reset = int(raw) if _is_num(raw) else 0 message, count = outcomes[code] return CodexResetRedeemResult(status=code, message=message, available_count=count, windows_reset=windows_reset) def redeem_codex_reset_credit( *, base_url: Optional[str] = None, api_key: Optional[str] = None, force: bool = False, ) -> CodexResetRedeemResult: """Redeem one banked Codex rate-limit reset credit (`/usage reset`), mirroring the Codex CLI picker: GET usage โ†’ guard (a reset restores the WHOLE 5h + weekly allowance, and the backend's own ``nothing_to_reset`` guard is less clear) โ†’ POST consume with a fresh UUID ``redeem_request_id`` and no ``credit_id`` (the backend picks the next credit). Never raises: every failure returns a result.""" import uuid try: token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key) except Exception: return _unavailable("No Codex credentials available. Run `hermes auth` to sign in with your ChatGPT account.") redeem_request_id = str(uuid.uuid4()) try: for attempt in range(2): usage_url, _credits_url, consume_url = _codex_backend_urls(resolved_base_url) headers = _codex_headers(token, account_id) try: with httpx.Client(timeout=15.0) as client: usage_resp = client.get(usage_url, headers=headers) usage_resp.raise_for_status() payload = usage_resp.json() or {} available = _codex_banked_resets(payload) refused = _codex_reset_guard(payload, available, force) if refused is not None: return refused consume_resp = client.post( consume_url, headers={**headers, "Content-Type": "application/json"}, json={"redeem_request_id": redeem_request_id}, ) consume_resp.raise_for_status() body = consume_resp.json() or {} break except httpx.HTTPStatusError as exc: if exc.response.status_code != 401 or attempt > 0: raise try: token, resolved_base_url, account_id = _resolve_codex_usage_credentials( base_url, api_key, force_refresh=True, ) except Exception: # Refresh token dead too: the 401 hint (re-login) is the actionable message. raise exc from None except httpx.HTTPStatusError as exc: code = exc.response.status_code if code in (401, 403): return _unavailable(f"Codex backend rejected the request (HTTP {code}). Reset credits require ChatGPT-account " "(OAuth) auth โ€” run `hermes auth` and sign in with your ChatGPT account.") return _unavailable(f"Codex backend error (HTTP {code}) โ€” try again shortly.") except Exception as exc: return _unavailable(f"Could not reach the Codex backend: {exc}") return _codex_reset_outcome(body, available) def _fetch_anthropic_account_usage( base_url: Optional[str] = None, api_key: Optional[str] = None ) -> Optional[AccountUsageSnapshot]: # An explicit api_key (live agent / per-credential pool read) must not be shadowed by # the ambient OAuth singleton: fetch_account_usage(base_url, api_key) promises to # report THAT credential's usage. An API key answers the OAuth usage endpoint with # 401, so only an OAuth-shaped token reaches the fetch. explicit = str(api_key or "").strip() token = explicit or (resolve_anthropic_token() or "").strip() if not token: return None if not _is_oauth_token(token): return _snapshot("anthropic", "oauth_usage_api", [], [], unavailable_reason="Anthropic account limits are only available for OAuth-backed Claude accounts.") headers = {"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json", "anthropic-beta": "oauth-2025-04-20", "User-Agent": "claude-code/2.1.0"} payload = _get_json("https://api.anthropic.com/api/oauth/usage", headers, timeout=15.0) windows = _usage_windows( payload, (("five_hour", "Current session"), ("seven_day", "Current week"), ("seven_day_opus", "Opus week"), ("seven_day_sonnet", "Sonnet week")), "utilization", "resets_at", fraction=True, model_scoped={"seven_day_opus", "seven_day_sonnet"}, ) details: list[str] = [] extra = payload.get("extra_usage") or {} used_credits, monthly_limit = extra.get("used_credits"), extra.get("monthly_limit") if extra.get("is_enabled") and _is_num(used_credits) and _is_num(monthly_limit): details.append(f"Extra usage: {used_credits:.2f} / {monthly_limit:.2f} {extra.get('currency') or 'USD'}") return _snapshot("anthropic", "oauth_usage_api", windows, details) def _fetch_openrouter_account_usage(base_url: Optional[str], api_key: Optional[str]) -> Optional[AccountUsageSnapshot]: runtime = resolve_runtime_provider(requested="openrouter", explicit_base_url=base_url, explicit_api_key=api_key) token = str(runtime.get("api_key", "") or "").strip() if not token: return None normalized = str(runtime.get("base_url", "") or "").rstrip("/") headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} with httpx.Client(timeout=10.0) as client: def _data(path: str) -> dict: resp = client.get(f"{normalized}/{path}", headers=headers) resp.raise_for_status() return (resp.json() or {}).get("data") or {} credits = _data("credits") try: key_data = _data("key") except Exception: key_data = {} balance = float(credits.get("total_credits") or 0.0) - float(credits.get("total_usage") or 0.0) details = [f"Credits balance: ${max(0.0, balance):.2f}"] windows: list[AccountUsageWindow] = [] limit, limit_remaining, usage = key_data.get("limit"), key_data.get("limit_remaining"), key_data.get("usage") limit_reset = str(key_data.get("limit_reset") or "").strip() if _is_num(limit) and float(limit) > 0 and _is_num(limit_remaining) and 0 <= float(limit_remaining) <= float(limit): limit_value, remaining_value = float(limit), float(limit_remaining) detail_parts = [f"${remaining_value:.2f} of ${limit_value:.2f} remaining", *([f"resets {limit_reset}"] if limit_reset else [])] windows.append(AccountUsageWindow(label="API key quota", used_percent=((limit_value - remaining_value) / limit_value) * 100, detail=" โ€ข ".join(detail_parts))) if _is_num(usage): usage_parts = [f"API key usage: ${float(usage):.2f} total"] for key, label in (("usage_daily", "today"), ("usage_weekly", "this week"), ("usage_monthly", "this month")): value = key_data.get(key) if _is_num(value) and float(value) > 0: usage_parts.append(f"${float(value):.2f} {label}") details.append(" โ€ข ".join(usage_parts)) return _snapshot("openrouter", "credits_api", windows, details) _USAGE_FETCHERS: dict[str, Callable[[Optional[str], Optional[str]], Optional[AccountUsageSnapshot]]] = { "openai-codex": _fetch_codex_account_usage, "anthropic": _fetch_anthropic_account_usage, "openrouter": _fetch_openrouter_account_usage, } # Picker per-credential variants: same parsers/fetch shape, but a failure is never repaired by # rotating/refreshing a credential (see ``fetch_account_usage(read_only=True)``). _READ_ONLY_USAGE_FETCHERS: dict[str, Callable[[Optional[str], Optional[str]], Optional[AccountUsageSnapshot]]] = { "openai-codex": _fetch_codex_account_usage_read_only, } # Wall-clock bound on a plugin profile's ``fetch_account_usage`` hook. The built-in fetchers above carry # their own httpx timeouts; a plugin hook is arbitrary code, and the gateway/TUI ``/usage`` paths await # this function with no deadline of their own (only the CLI wraps it in a 10 s future), so the bound # lives here where every surface shares it. PLUGIN_USAGE_HOOK_DEADLINE_S = 10.0 def _call_plugin_usage_hook(profile, base_url: Optional[str], api_key: Optional[str]) -> Optional[AccountUsageSnapshot]: """Run the profile hook under the shared deadline; past it โ†’ None. Exceptions re-raise in the caller so ``fetch_account_usage`` fails open without a worker-thread traceback on ``/usage``.""" from agent.deadline import run_bounded_sync from providers.base import ProviderProfile if type(profile).fetch_account_usage is ProviderProfile.fetch_account_usage: return None # base no-op: no thread to spawn bounded = run_bounded_sync( lambda: profile.fetch_account_usage(base_url=base_url, api_key=api_key), PLUGIN_USAGE_HOOK_DEADLINE_S, label="plugin-account-usage") return None if bounded.timed_out else bounded.value def fetch_account_usage( provider: Optional[str], *, base_url: Optional[str] = None, api_key: Optional[str] = None, read_only: bool = False, identity_id: Optional[str] = None, ) -> Optional[AccountUsageSnapshot]: from agent.account_usage_cache import remember_account_usage slug = str(provider or "").strip().lower() # The picker's per-credential path resolves variants that never rotate/refresh a credential # on failure (Codex 401), so a read cannot repair or bench a pool entry it does not own. fetcher = _READ_ONLY_USAGE_FETCHERS.get(slug) if read_only else None if fetcher is None: fetcher = _USAGE_FETCHERS.get(slug) import time as _time started = _time.monotonic() try: if fetcher: snapshot = fetcher(base_url, api_key) else: from providers import get_provider_profile profile = get_provider_profile(slug) snapshot = _call_plugin_usage_hook(profile, base_url, api_key) if profile else None except Exception: return None # Every fetch (``/usage``, the per-turn ``session.usage``) keeps the picker's cache current. # A per-credential fetch is remembered under its account slot only; the provider-wide fetch # keeps both the legacy gauge slot and (when the fetcher identifies the account) that # account's slot current. remember_account_usage(provider, snapshot, identity_id=identity_id, base_url=base_url, started_monotonic=started) return snapshot