From b87548b68ef6e2553de34277035968eb2d04b5a0 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Tue, 12 May 2026 14:50:23 -0600 Subject: [PATCH] fix(providers): show pooled Codex quota status --- api/providers.py | 422 +++++++++++++++++++++++++--- static/panels.js | 82 +++++- static/style.css | 41 ++- tests/test_provider_quota_status.py | 414 ++++++++++++++++++++++++--- 4 files changed, 875 insertions(+), 84 deletions(-) diff --git a/api/providers.py b/api/providers.py index 9734e111..be53358a 100644 --- a/api/providers.py +++ b/api/providers.py @@ -7,6 +7,8 @@ multi-provider support). from __future__ import annotations +import base64 +import hashlib import json import logging import os @@ -14,6 +16,7 @@ import signal import subprocess import sys import threading +import time import urllib.error import urllib.request from datetime import datetime, timezone @@ -53,6 +56,8 @@ def _custom_provider_name_matches(provider_id: str, name: object) -> bool: _OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key" _PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0 _ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS = 35.0 +_ACCOUNT_USAGE_CACHE_TTL_SECONDS = 45.0 +_ACCOUNT_USAGE_CACHE_MAX_ENTRIES = 64 _ACCOUNT_USAGE_PROVIDERS = frozenset({"openai-codex", "anthropic"}) # Upper bound on simultaneous profile-isolated quota probe subprocesses. @@ -98,6 +103,14 @@ _ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP = ( # Lazily created so this module compiles even when threading isn't ready. _account_usage_probe_semaphore: threading.BoundedSemaphore | None = None +# Short-lived account-usage cache. The Codex pooled probe may check multiple +# credentials, so cache the sanitized snapshot briefly to avoid re-querying the +# provider on every Settings repaint/profile-panel refresh. Pool composition +# changes can be stale for at most this TTL; that is preferred to hammering the +# provider usage API while the Settings panel is open. +_account_usage_status_cache: dict[tuple[str, str, str], tuple[float, Any]] = {} +_account_usage_status_cache_lock = threading.Lock() + def _get_account_usage_probe_semaphore() -> threading.BoundedSemaphore: global _account_usage_probe_semaphore @@ -136,6 +149,7 @@ from agent.account_usage import fetch_account_usage _CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex" +_CODEX_POOL_USAGE_TIMEOUT_SECONDS = 4.0 def _iso(value): @@ -159,7 +173,7 @@ def _snapshot_payload(snapshot): "reset_at": _iso(getattr(window, "reset_at", None)), "detail": getattr(window, "detail", None), }) - return { + payload = { "provider": str(getattr(snapshot, "provider", "") or ""), "source": str(getattr(snapshot, "source", "") or ""), "title": str(getattr(snapshot, "title", "") or ""), @@ -170,6 +184,10 @@ def _snapshot_payload(snapshot): "unavailable_reason": getattr(snapshot, "unavailable_reason", None), "fetched_at": _iso(getattr(snapshot, "fetched_at", None)), } + pool = getattr(snapshot, "pool", None) + if isinstance(pool, dict): + payload["pool"] = pool + return payload def _snapshot_available(snapshot): @@ -317,25 +335,203 @@ def _codex_snapshot_from_usage_payload(payload): ) +def _snapshot_windows_payload(snapshot): + windows = [] + for window in getattr(snapshot, "windows", ()) or (): + label = str(getattr(window, "label", "") or "").strip() + if not label: + continue + used_percent = _number(getattr(window, "used_percent", None)) + remaining_percent = None + if used_percent is not None: + remaining_percent = max(0.0, min(100.0, 100.0 - float(used_percent))) + windows.append({ + "label": label, + "used_percent": used_percent, + "remaining_percent": remaining_percent, + "reset_at": _iso(getattr(window, "reset_at", None)), + "detail": getattr(window, "detail", None), + }) + return windows + + +def _snapshot_details_payload(snapshot): + return [ + str(detail).strip() + for detail in (getattr(snapshot, "details", ()) or ()) + if str(detail).strip() + ] + + +def _safe_entry_label(entry, index): + label = _entry_value(entry, "label", "source") or "" + if not label: + label = "Credential " + str(index) + label = " ".join(str(label).split()) + if len(label) > 64: + label = label[:61].rstrip() + "..." + return label + + +def _safe_unavailable_reason(reason): + text = " ".join(str(reason or "").split()) + if not text: + return None + lowered = text.lower() + sensitive_terms = ("access_token", "refresh_token", "authorization", "bearer ", "jwt", "secret") + if any(term in lowered for term in sensitive_terms): + return "Usage unavailable for this credential." + return text[:180] + + +def _entry_is_pool_exhausted(entry): + return str(_entry_value(entry, "last_status") or "").strip().lower() == "exhausted" + + +def _fetch_codex_entry_snapshot(entry): + access_token = _entry_value(entry, "runtime_api_key", "access_token") + if not access_token: + return None, False, "No runtime token available." + base_url = _entry_value(entry, "runtime_base_url", "base_url") or _CODEX_DEFAULT_BASE_URL + request = urllib_request.Request( + _resolve_codex_usage_url(base_url), + headers=_codex_usage_headers(access_token), + ) + with urllib_request.urlopen(request, timeout=_CODEX_POOL_USAGE_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8") or "{}") + return _codex_snapshot_from_usage_payload(payload), True, None + + +def _best_remaining_by_window(rows): + best = {} + for row in rows: + if row.get("status") != "available": + continue + label = row.get("label") or "Credential" + for window in row.get("windows") or []: + if not isinstance(window, dict): + continue + window_label = str(window.get("label") or "").strip() + remaining = _number(window.get("remaining_percent")) + if not window_label or remaining is None: + continue + candidate = { + "label": window_label, + "remaining_percent": remaining, + "used_percent": window.get("used_percent"), + "reset_at": window.get("reset_at"), + "detail": window.get("detail"), + "credential_label": label, + } + current = best.get(window_label.lower()) + if current is None or float(remaining) > float(current.get("remaining_percent") or -1): + best[window_label.lower()] = candidate + return list(best.values()) + + +def _next_reset_at(rows): + best_dt = None + best_text = None + for row in rows: + for window in row.get("windows") or []: + if not isinstance(window, dict): + continue + reset_text = window.get("reset_at") + dt = _parse_dt(reset_text) + if dt is None: + continue + if best_dt is None or dt < best_dt: + best_dt = dt + best_text = _iso(dt) + return best_text + + +def _codex_pool_snapshot(entries, rows, queried): + available_rows = [row for row in rows if row.get("status") == "available"] + exhausted_rows = [row for row in rows if row.get("status") == "exhausted"] + failed_rows = [row for row in rows if row.get("status") not in {"available", "exhausted"}] + plans = [] + for row in rows: + plan = row.get("plan") + if plan and plan not in plans: + plans.append(plan) + best_windows = _best_remaining_by_window(rows) + pool = { + "total_credentials": len(entries), + "queried_credentials": queried, + "available_credentials": len(available_rows), + "exhausted_credentials": len(exhausted_rows), + "failed_credentials": len(failed_rows), + "plans": plans, + "next_reset_at": _next_reset_at(rows), + "best_remaining_by_window": best_windows, + "credentials": rows, + } + details = [str(len(available_rows)) + "/" + str(len(entries)) + " credentials available"] + if exhausted_rows: + details.append(str(len(exhausted_rows)) + " exhausted") + if failed_rows: + details.append(str(len(failed_rows)) + " failed to load") + if plans: + details.append("Plans: " + ", ".join(plans)) + plan = plans[0] if len(plans) == 1 else None + windows = tuple( + SimpleNamespace( + label=window.get("label"), + used_percent=window.get("used_percent"), + reset_at=window.get("reset_at"), + detail="Best of " + str(len(available_rows)) + " available credentials", + ) + for window in best_windows + ) + return SimpleNamespace( + provider="openai-codex", + source="usage_api_pool", + title="Account limits", + plan=plan, + windows=windows, + details=tuple(details), + available=bool(available_rows), + unavailable_reason=None if available_rows else "No Codex pool credentials returned available account limits.", + fetched_at=datetime.now(timezone.utc), + pool=pool, + ) + + def _fetch_codex_account_usage_from_pool(): try: from agent.credential_pool import load_pool pool = load_pool("openai-codex") - entry = pool.select() if pool is not None else None - if entry is None: + entries = pool.entries() if pool is not None and hasattr(pool, "entries") else [] + if not entries: return None - access_token = _entry_value(entry, "runtime_api_key", "access_token") - if not access_token: - return None - base_url = _entry_value(entry, "runtime_base_url", "base_url") or _CODEX_DEFAULT_BASE_URL - request = urllib_request.Request( - _resolve_codex_usage_url(base_url), - headers=_codex_usage_headers(access_token), - ) - with urllib_request.urlopen(request, timeout=15.0) as response: - payload = json.loads(response.read().decode("utf-8") or "{}") - return _codex_snapshot_from_usage_payload(payload) + rows = [] + queried = 0 + for index, entry in enumerate(entries, start=1): + label = _safe_entry_label(entry, index) + pool_exhausted = _entry_is_pool_exhausted(entry) + try: + snapshot, did_query, reason = _fetch_codex_entry_snapshot(entry) + if did_query: + queried += 1 + except Exception as exc: + snapshot = None + reason = str(exc) + windows = _snapshot_windows_payload(snapshot) if snapshot is not None else [] + details = _snapshot_details_payload(snapshot) if snapshot is not None else [] + snapshot_available = _snapshot_available(snapshot) + status = "available" if snapshot_available and not pool_exhausted else "exhausted" if pool_exhausted else "unavailable" + rows.append({ + "label": label, + "status": status, + "plan": getattr(snapshot, "plan", None) if snapshot is not None else None, + "windows": windows, + "details": details, + "unavailable_reason": None if snapshot_available else _safe_unavailable_reason(reason or getattr(snapshot, "unavailable_reason", None)), + "fetched_at": _iso(getattr(snapshot, "fetched_at", None)) if snapshot is not None else None, + }) + return _codex_pool_snapshot(entries, rows, queried) except Exception: return None @@ -346,10 +542,12 @@ try: snapshot = fetch_account_usage(provider, api_key=api_key) except Exception: snapshot = None -if str(provider or "").strip().lower() == "openai-codex" and not _snapshot_available(snapshot): - fallback_snapshot = _fetch_codex_account_usage_from_pool() - if _snapshot_available(fallback_snapshot) or snapshot is None: - snapshot = fallback_snapshot +if str(provider or "").strip().lower() == "openai-codex": + pool_snapshot = _fetch_codex_account_usage_from_pool() + if isinstance(getattr(pool_snapshot, "pool", None), dict): + snapshot = pool_snapshot + elif not _snapshot_available(snapshot) and _snapshot_available(pool_snapshot): + snapshot = pool_snapshot print(json.dumps(_snapshot_payload(snapshot))) """ @@ -447,6 +645,85 @@ def _load_env_file(env_path: Path) -> dict[str, str]: return values +def _decode_jwt_claims_unverified(token: str) -> dict[str, Any]: + if not isinstance(token, str) or token.count(".") != 2: + return {} + payload = token.split(".", 2)[1] + payload += "=" * ((4 - len(payload) % 4) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8")) + except Exception: + return {} + return claims if isinstance(claims, dict) else {} + + +def _looks_like_codex_oauth_token(value: str) -> bool: + """Return True when a value is a ChatGPT/Codex OAuth JWT, not an OpenAI API key.""" + token = str(value or "").strip() + if not token or token.startswith("sk-"): + return False + claims = _decode_jwt_claims_unverified(token) + if not claims: + return False + auth_claim = claims.get("https://api.openai.com/auth") + if isinstance(auth_claim, dict) and auth_claim: + return True + return any(key in claims for key in ("chatgpt_account_id", "https://api.openai.com/profile")) + + +def _provider_value_counts_as_api_key(provider_id: str, value: object) -> bool: + text = str(value or "").strip() + if not text: + return False + if (provider_id or "").strip().lower() == "openai" and _looks_like_codex_oauth_token(text): + return False + return True + + +def _provider_has_shadowed_codex_oauth_value(provider_id: str) -> bool: + """True when the bare OpenAI credential slot contains only a Codex OAuth JWT. + + Users who authenticate Codex can end up with a ChatGPT/Codex JWT in a + legacy OPENAI_API_KEY-shaped location. That value should not make the bare + OpenAI API provider appear as configured, and the Providers tab should not + show an extra OpenAI card solely because of that Codex-only credential. + """ + if (provider_id or "").strip().lower() != "openai": + return False + values: list[object] = [] + env_var = _PROVIDER_ENV_VAR.get(provider_id) + if env_var: + env_path = _get_hermes_home() / ".env" + env_values = _load_env_file(env_path) + values.append(env_values.get(env_var)) + values.append(os.getenv(env_var)) + for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or (): + values.append(env_values.get(alias)) + values.append(os.getenv(alias)) + + cfg = get_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + active_provider = str(model_cfg.get("provider") or "").strip().lower() + if active_provider == provider_id: + values.append(model_cfg.get("api_key")) + providers_cfg = cfg.get("providers", {}) + if isinstance(providers_cfg, dict): + provider_cfg = providers_cfg.get(provider_id, {}) + if isinstance(provider_cfg, dict): + values.append(provider_cfg.get("api_key")) + custom_providers = cfg.get("custom_providers", []) + if isinstance(custom_providers, list): + for cp in custom_providers: + if isinstance(cp, dict) and _custom_provider_name_matches(provider_id, cp.get("name")): + cp_key = cp.get("api_key") + if isinstance(cp_key, str) and cp_key.startswith("${") and cp_key.endswith("}"): + values.append(os.getenv(cp_key[2:-1])) + else: + values.append(cp_key) + return any(_looks_like_codex_oauth_token(str(value or "")) for value in values) + + def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None: """Write key=value pairs to the .env file. @@ -559,17 +836,19 @@ def _provider_has_key(provider_id: str) -> bool: if env_var: env_path = _get_hermes_home() / ".env" env_values = _load_env_file(env_path) - if env_values.get(env_var): + env_file_value = env_values.get(env_var) + if _provider_value_counts_as_api_key(provider_id, env_file_value): return True - if os.getenv(env_var): + env_value = os.getenv(env_var) + if _provider_value_counts_as_api_key(provider_id, env_value): return True # Fall back to legacy env-var aliases (e.g. lmstudio's pre-#1500 # LMSTUDIO_API_KEY name) so existing users don't lose detection # after an env-var rename. See _PROVIDER_ENV_VAR_ALIASES. for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or (): - if env_values.get(alias): + if _provider_value_counts_as_api_key(provider_id, env_values.get(alias)): return True - if os.getenv(alias): + if _provider_value_counts_as_api_key(provider_id, os.getenv(alias)): return True cfg = get_config() @@ -580,20 +859,22 @@ def _provider_has_key(provider_id: str) -> bool: if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip(): active_provider = model_cfg.get("provider") if active_provider and str(active_provider).strip().lower() == provider_id.lower(): - return True + if _provider_value_counts_as_api_key(provider_id, model_cfg.get("api_key")): + return True # Check providers..api_key providers_cfg = cfg.get("providers", {}) if isinstance(providers_cfg, dict): provider_cfg = providers_cfg.get(provider_id, {}) if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip(): - return True + if _provider_value_counts_as_api_key(provider_id, provider_cfg.get("api_key")): + return True # Check custom_providers custom_providers = cfg.get("custom_providers", []) if isinstance(custom_providers, list): for cp in custom_providers: if isinstance(cp, dict): if _custom_provider_name_matches(provider_id, cp.get("name")): - if str(cp.get("api_key") or "").strip(): + if _provider_value_counts_as_api_key(provider_id, cp.get("api_key")): return True return False @@ -605,22 +886,26 @@ def _get_provider_api_key(provider_id: str) -> str | None: if env_var: env_path = _get_hermes_home() / ".env" env_values = _load_env_file(env_path) - if env_values.get(env_var): - return str(env_values[env_var]).strip() or None - if os.getenv(env_var): - return os.getenv(env_var, "").strip() or None + env_file_value = env_values.get(env_var) + if _provider_value_counts_as_api_key(provider_id, env_file_value): + return str(env_file_value).strip() or None + env_value = os.getenv(env_var) + if _provider_value_counts_as_api_key(provider_id, env_value): + return str(env_value).strip() or None for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or (): - if env_values.get(alias): - return str(env_values[alias]).strip() or None - if os.getenv(alias): - return os.getenv(alias, "").strip() or None + alias_file_value = env_values.get(alias) + if _provider_value_counts_as_api_key(provider_id, alias_file_value): + return str(alias_file_value).strip() or None + alias_value = os.getenv(alias) + if _provider_value_counts_as_api_key(provider_id, alias_value): + return str(alias_value).strip() or None cfg = get_config() model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): active_provider = str(model_cfg.get("provider") or "").strip().lower() model_key = str(model_cfg.get("api_key") or "").strip() - if model_key and active_provider == provider_id: + if model_key and active_provider == provider_id and _provider_value_counts_as_api_key(provider_id, model_key): return model_key providers_cfg = cfg.get("providers", {}) @@ -628,7 +913,7 @@ def _get_provider_api_key(provider_id: str) -> str | None: provider_cfg = providers_cfg.get(provider_id, {}) if isinstance(provider_cfg, dict): provider_key = str(provider_cfg.get("api_key") or "").strip() - if provider_key: + if _provider_value_counts_as_api_key(provider_id, provider_key): return provider_key custom_providers = cfg.get("custom_providers", []) @@ -640,7 +925,7 @@ def _get_provider_api_key(provider_id: str) -> str | None: cp_key = str(cp.get("api_key") or "").strip() if cp_key.startswith("${") and cp_key.endswith("}"): return os.getenv(cp_key[2:-1], "").strip() or None - if cp_key: + if _provider_value_counts_as_api_key(provider_id, cp_key): return cp_key return None @@ -718,7 +1003,7 @@ def _serialize_account_usage_snapshot(snapshot: Any) -> dict[str, Any] | None: ] plan = str(getattr(snapshot, "plan", "") or "").strip() or None unavailable_reason = str(getattr(snapshot, "unavailable_reason", "") or "").strip() or None - return { + result = { "provider": str(getattr(snapshot, "provider", "") or "").strip() or None, "source": str(getattr(snapshot, "source", "") or "").strip() or None, "title": str(getattr(snapshot, "title", "") or "").strip() or "Account limits", @@ -729,6 +1014,10 @@ def _serialize_account_usage_snapshot(snapshot: Any) -> dict[str, Any] | None: "unavailable_reason": unavailable_reason, "fetched_at": _isoformat_utc(getattr(snapshot, "fetched_at", None)), } + pool = getattr(snapshot, "pool", None) + if isinstance(pool, dict): + result["pool"] = pool + return result def _agent_fetch_account_usage(provider: str, *, base_url: str | None = None, api_key: str | None = None) -> Any: @@ -791,9 +1080,48 @@ def _account_usage_payload_to_snapshot(payload: Any) -> Any: available=bool(payload.get("available")), unavailable_reason=payload.get("unavailable_reason"), fetched_at=payload.get("fetched_at"), + pool=payload.get("pool") if isinstance(payload.get("pool"), dict) else None, ) +def _account_usage_cache_key(provider: str, home: Path, api_key: str | None) -> tuple[str, str, str]: + key_fingerprint = "" + if api_key: + key_fingerprint = hashlib.sha256(api_key.encode("utf-8", "ignore")).hexdigest() + return ((provider or "").strip().lower(), str(Path(home)), key_fingerprint) + + +def _get_cached_account_usage(cache_key: tuple[str, str, str]) -> tuple[bool, Any]: + now = time.monotonic() + with _account_usage_status_cache_lock: + cached = _account_usage_status_cache.get(cache_key) + if cached is None: + return False, None + fetched_at, snapshot = cached + if now - fetched_at <= _ACCOUNT_USAGE_CACHE_TTL_SECONDS: + return True, snapshot + _account_usage_status_cache.pop(cache_key, None) + return False, None + + +def _set_cached_account_usage(cache_key: tuple[str, str, str], snapshot: Any) -> None: + now = time.monotonic() + with _account_usage_status_cache_lock: + _account_usage_status_cache[cache_key] = (now, snapshot) + expired = [ + key for key, (fetched_at, _snapshot) in _account_usage_status_cache.items() + if now - fetched_at > _ACCOUNT_USAGE_CACHE_TTL_SECONDS + ] + for key in expired: + _account_usage_status_cache.pop(key, None) + while len(_account_usage_status_cache) > _ACCOUNT_USAGE_CACHE_MAX_ENTRIES: + oldest_key = min( + _account_usage_status_cache, + key=lambda key: _account_usage_status_cache[key][0], + ) + _account_usage_status_cache.pop(oldest_key, None) + + def _agent_fetch_account_usage_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any: try: from api.config import PYTHON_EXE @@ -856,16 +1184,23 @@ def _fetch_account_usage_with_profile_context(provider: str) -> Any: """ home = _get_hermes_home() api_key = _get_provider_api_key(provider) + cache_key = _account_usage_cache_key(provider, home, api_key) + cache_hit, cached = _get_cached_account_usage(cache_key) + if cache_hit: + return cached sem = _get_account_usage_probe_semaphore() try: with sem: - return _agent_fetch_account_usage_for_home( + snapshot = _agent_fetch_account_usage_for_home( provider, home, api_key=api_key, ) + _set_cached_account_usage(cache_key, snapshot) + return snapshot except Exception: logger.debug("Failed to fetch account usage for %s", provider, exc_info=True) + _set_cached_account_usage(cache_key, None) return None @@ -1075,9 +1410,9 @@ def get_providers() -> dict[str, Any]: if env_var: env_path = _get_hermes_home() / ".env" env_values = _load_env_file(env_path) - if env_values.get(env_var): + if _provider_value_counts_as_api_key(pid, env_values.get(env_var)): key_source = "env_file" - elif os.getenv(env_var): + elif _provider_value_counts_as_api_key(pid, os.getenv(env_var)): key_source = "env_var" else: # Canonical name not set; check legacy aliases (e.g. lmstudio's @@ -1086,11 +1421,11 @@ def get_providers() -> dict[str, Any]: # actually lives in .env under the old name. aliased = False for alias in _PROVIDER_ENV_VAR_ALIASES.get(pid, ()) or (): - if env_values.get(alias): + if _provider_value_counts_as_api_key(pid, env_values.get(alias)): key_source = "env_file" aliased = True break - if os.getenv(alias): + if _provider_value_counts_as_api_key(pid, os.getenv(alias)): key_source = "env_var" aliased = True break @@ -1124,6 +1459,9 @@ def get_providers() -> dict[str, Any]: except Exception: pass + if pid == "openai" and not has_key and _provider_has_shadowed_codex_oauth_value(pid): + continue + models = list(_PROVIDER_MODELS.get(pid, [])) models_total = len(models) # OpenAI Codex account catalogs drift independently from WebUI releases. diff --git a/static/panels.js b/static/panels.js index d942fac0..49a93dd4 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5593,6 +5593,74 @@ function _formatProviderQuotaLastChecked(status){ try{return 'Last checked '+d.toLocaleString();}catch(e){return 'Last checked '+value;} } +function _providerQuotaStateClass(value){ + return String(value||'unavailable').replace(/[^a-z0-9_-]/gi,'').toLowerCase()||'unavailable'; +} + +function _providerQuotaPoolShouldDefaultOpen(pool){ + try{ + const saved=localStorage.getItem('hermes-provider-quota-pool-open'); + if(saved==='1') return true; + if(saved==='0') return false; + }catch(e){} + const count=Array.isArray(pool&&pool.credentials)?pool.credentials.length:0; + return count>0&&count<=3; +} + +function _buildProviderQuotaPoolBreakdown(accountLimits){ + const pool=accountLimits&&accountLimits.pool; + if(!pool||!Array.isArray(pool.credentials)||pool.credentials.length===0) return ''; + const defaultOpen=_providerQuotaPoolShouldDefaultOpen(pool); + const total=Number.isFinite(Number(pool.total_credentials))?Number(pool.total_credentials):pool.credentials.length; + const available=Number.isFinite(Number(pool.available_credentials))?Number(pool.available_credentials):pool.credentials.filter(c=>c&&c.status==='available').length; + const exhausted=Number.isFinite(Number(pool.exhausted_credentials))?Number(pool.exhausted_credentials):0; + const failed=Number.isFinite(Number(pool.failed_credentials))?Number(pool.failed_credentials):0; + const queried=Number.isFinite(Number(pool.queried_credentials))?Number(pool.queried_credentials):0; + const summaryParts=[`${available}/${total} available`]; + if(exhausted>0) summaryParts.push(`${exhausted} exhausted`); + if(failed>0) summaryParts.push(`${failed} failed`); + if(queried>0) summaryParts.push(`${queried} checked`); + const planParts=Array.isArray(pool.plans)?pool.plans.filter(Boolean):[]; + const rows=pool.credentials.map((credential,idx)=>{ + const label=(credential&&credential.label)||`Credential ${idx+1}`; + const status=_providerQuotaStateClass(credential&&credential.status); + const statusText=status.replace(/_/g,' '); + const plan=credential&&credential.plan?` · ${credential.plan}`:''; + const windows=Array.isArray(credential&&credential.windows)?credential.windows:[]; + const details=Array.isArray(credential&&credential.details)?credential.details.filter(Boolean):[]; + const unavailableReason=(credential&&credential.unavailable_reason)||''; + const windowHtml=windows.length?windows.map(w=>{ + const remaining=_formatProviderQuotaPercent(w&&w.remaining_percent); + const used=_formatProviderQuotaPercent(w&&w.used_percent); + const reset=_formatProviderQuotaReset(w&&w.reset_at); + const meta=[]; + if(used!=='—') meta.push(`${used} used`); + if(reset) meta.push(`resets ${reset}`); + const detail=(w&&w.detail)?String(w.detail).trim():''; + return `
${esc(_formatProviderQuotaWindowLabel(accountLimits,w))}${esc(remaining)}${meta.length?`${esc(meta.join(' · '))}`:''}${detail?`${esc(detail)}`:''}
`; + }).join(''):`
${esc(unavailableReason||'No account-limit windows reported.')}
`; + const detailHtml=details.length?`
${details.map(d=>`${esc(d)}`).join('')}
`:''; + return ` +
+
+ ${esc(label)}${esc(plan)} + ${esc(statusText)} +
+
${windowHtml}
+ ${detailHtml} +
+ `; + }).join(''); + const planText=planParts.length?`
Plans: ${esc(planParts.join(', '))}
`:''; + return ` +
+ Credential pool${esc(summaryParts.join(' · '))} + ${planText} +
${rows}
+
+ `; +} + function _buildProviderQuotaCard(status){ if(!status) return null; const card=document.createElement('div'); @@ -5603,7 +5671,7 @@ function _buildProviderQuotaCard(status){ const provider=(accountLimits&&accountLimits.plan)?`${providerBase} · ${accountLimits.plan}`:providerBase; const quota=status.quota||null; let body=''; - if(status.status==='available'&&accountLimits){ + if(accountLimits&&(status.status==='available'||accountLimits.pool)){ const windows=Array.isArray(accountLimits.windows)?accountLimits.windows:[]; const details=Array.isArray(accountLimits.details)?accountLimits.details:[]; const windowHtml=windows.map(w=>{ @@ -5612,19 +5680,21 @@ function _buildProviderQuotaCard(status){ const meta=[]; if(used!=='—') meta.push(`${used} used`); if(reset) meta.push(`resets ${reset}`); - if(w&&w.detail) meta.push(w.detail); + const detail=(w&&w.detail)?String(w.detail).trim():''; return `
${esc(_formatProviderQuotaWindowLabel(accountLimits,w))} ${esc(_formatProviderQuotaPercent(w&&w.remaining_percent))} ${meta.length?`${esc(meta.join(' · '))}`:''} + ${detail?`${esc(detail)}`:''}
`; }).join(''); const detailHtml=details.length ? `
${details.map(d=>`${esc(d)}`).join('')}
` : ''; - body=windowHtml+detailHtml; + const poolHtml=_buildProviderQuotaPoolBreakdown(accountLimits); + body=windowHtml+detailHtml+poolHtml; if(!body) body=`
${esc(status.message||'Account limits loaded.')}
`; }else if(status.status==='available'&"a){ body=` @@ -5651,6 +5721,12 @@ function _buildProviderQuotaCard(status){ `; const refreshBtn=card.querySelector('[data-provider-quota-refresh]'); if(refreshBtn) refreshBtn.addEventListener('click',()=>_refreshProviderQuota(card,refreshBtn)); + const poolDetails=card.querySelector('.provider-quota-pool'); + if(poolDetails){ + poolDetails.addEventListener('toggle',()=>{ + try{localStorage.setItem('hermes-provider-quota-pool-open',poolDetails.open?'1':'0');}catch(e){} + }); + } return card; } diff --git a/static/style.css b/static/style.css index 98661718..449fc23e 100644 --- a/static/style.css +++ b/static/style.css @@ -2490,6 +2490,7 @@ main.main.showing-logs > #mainLogs{display:flex;} background:linear-gradient(180deg,var(--surface),var(--sidebar)); padding:12px 16px; margin-bottom:12px; + min-width:0; } .provider-quota-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;} .provider-quota-title{font-size:13px;font-weight:650;color:var(--text);line-height:1.2;} @@ -2500,19 +2501,57 @@ main.main.showing-logs > #mainLogs{display:flex;} .provider-quota-refresh{appearance:none;border:1px solid var(--border);border-radius:999px;background:var(--sidebar);color:var(--text);font-size:11px;font-weight:650;line-height:1;padding:5px 9px;cursor:pointer;white-space:nowrap;} .provider-quota-refresh:hover:not(:disabled){border-color:var(--accent);color:var(--accent);} .provider-quota-refresh:disabled{opacity:.65;cursor:wait;} -.provider-quota-body{display:flex;flex-wrap:wrap;gap:8px;} +.provider-quota-body{display:flex;flex-wrap:wrap;gap:8px;min-width:0;} .provider-quota-metric{flex:1;min-width:88px;border:1px solid var(--border);border-radius:8px;background:var(--sidebar);padding:8px 10px;} .provider-quota-metric span{display:block;font-size:10.5px;color:var(--muted);margin-bottom:2px;} .provider-quota-metric strong{display:block;font-size:14px;color:var(--text);font-weight:650;} .provider-quota-metric small{display:block;font-size:10.5px;color:var(--muted);line-height:1.35;margin-top:3px;} +.provider-quota-window-detail{opacity:.9;} .provider-quota-window{min-width:132px;} .provider-quota-details{display:flex;flex-wrap:wrap;gap:6px;width:100%;} .provider-quota-details span{font-size:11px;line-height:1.35;color:var(--muted);border:1px solid var(--border);border-radius:999px;background:var(--sidebar);padding:4px 8px;} .provider-quota-message{font-size:12px;color:var(--muted);line-height:1.45;} +.provider-quota-pool{width:100%;min-width:0;border:1px solid var(--border);border-radius:10px;background:var(--sidebar);overflow:hidden;} +.provider-quota-pool summary{display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;list-style:none;padding:9px 10px;font-size:12px;color:var(--text);font-weight:650;} +.provider-quota-pool summary::-webkit-details-marker{display:none;} +.provider-quota-pool summary strong{font-size:11px;color:var(--muted);font-weight:600;text-align:right;} +.provider-quota-pool-plans{padding:0 10px 8px;font-size:11px;color:var(--muted);} +.provider-quota-pool-rows{display:flex;flex-direction:column;gap:8px;padding:0 10px 10px;min-width:0;} +.provider-quota-pool-row{border:1px solid var(--border);border-radius:8px;background:var(--surface);padding:8px;min-width:0;} +.provider-quota-pool-row-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:7px;font-size:12px;line-height:1.3;} +.provider-quota-pool-row-head span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);} +.provider-quota-pool-row-head strong{font-size:10px;text-transform:capitalize;border-radius:999px;padding:2px 7px;background:var(--accent-bg);color:var(--accent-text);white-space:nowrap;} +.provider-quota-pool-row-available .provider-quota-pool-row-head strong{background:rgba(34,197,94,.12);color:#16a34a;} +:root.dark .provider-quota-pool-row-available .provider-quota-pool-row-head strong{background:rgba(34,197,94,.16);color:#4ade80;} +.provider-quota-pool-row-exhausted .provider-quota-pool-row-head strong{background:rgba(234,179,8,.12);color:var(--warning);} +.provider-quota-pool-windows{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:6px;} +.provider-quota-pool-window{min-width:0;border:1px solid var(--border);border-radius:7px;background:var(--sidebar);padding:7px 8px;} +.provider-quota-pool-window span{display:block;font-size:10.5px;color:var(--muted);margin-bottom:2px;} +.provider-quota-pool-window strong{display:block;font-size:13px;color:var(--text);font-weight:650;} +.provider-quota-pool-window small{display:block;font-size:10px;color:var(--muted);line-height:1.3;margin-top:2px;} +.provider-quota-pool-note{font-size:11px;color:var(--muted);line-height:1.4;} +.provider-quota-pool-details{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px;} +.provider-quota-pool-details span{font-size:10.5px;line-height:1.3;color:var(--muted);border:1px solid var(--border);border-radius:999px;background:var(--sidebar);padding:3px 7px;} .provider-quota-card-available .provider-quota-badge{background:rgba(34,197,94,.12);color:#16a34a;} :root.dark .provider-quota-card-available .provider-quota-badge{background:rgba(34,197,94,.16);color:#4ade80;} .provider-quota-card-no_key .provider-quota-badge,.provider-quota-card-unsupported .provider-quota-badge{background:rgba(234,179,8,.12);color:var(--warning);} .provider-quota-card-invalid_key .provider-quota-badge{background:color-mix(in srgb,var(--error) 12%,transparent);color:var(--error);} +@media (max-width: 520px){ + .provider-quota-card{padding:12px;} + .provider-quota-header{flex-wrap:wrap;} + .provider-quota-body{flex-direction:column;} + .provider-quota-metric{width:100%;min-width:0;flex:none;} + .provider-quota-window{min-width:0;} + .provider-quota-pool summary{align-items:flex-start;flex-direction:column;gap:4px;} + .provider-quota-pool summary strong{text-align:left;} + .provider-quota-pool-rows{padding:0 8px 8px;} + .provider-quota-pool-row{padding:8px;} + .provider-quota-pool-row-head{align-items:flex-start;flex-wrap:wrap;} + .provider-quota-metric small,.provider-quota-pool-window small{overflow-wrap:anywhere;} + .provider-quota-details span,.provider-quota-pool-details span{max-width:100%;white-space:normal;} + .provider-quota-pool-row-head span{white-space:normal;} + .provider-quota-pool-windows{grid-template-columns:1fr;} +} .provider-card{ border:1px solid var(--border); border-radius:12px; diff --git a/tests/test_provider_quota_status.py b/tests/test_provider_quota_status.py index d50c40b6..05ac5bd1 100644 --- a/tests/test_provider_quota_status.py +++ b/tests/test_provider_quota_status.py @@ -278,27 +278,31 @@ def test_codex_account_usage_unavailable_is_sanitized(monkeypatch, tmp_path): assert "secret" not in repr(result).lower() -def test_codex_account_usage_subprocess_falls_back_to_credential_pool(monkeypatch, capsys): - """Codex quota probes should use credential_pool credentials when legacy auth misses.""" +def test_codex_account_usage_subprocess_reports_read_only_credential_pool(monkeypatch, capsys): + """Codex quota probes should inspect pool entries without mutating selection order.""" import api.providers as providers def b64url(payload: bytes) -> str: return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") - token = ".".join(( - b64url(b'{"alg":"none","typ":"JWT"}'), - b64url(json.dumps({ - "https://api.openai.com/auth": { - "chatgpt_account_id": "acct-test-123", - }, - }).encode("utf-8")), - b64url(b"signature"), - )) + def token_for(account_id: str) -> str: + return ".".join(( + b64url(b'{"alg":"none","typ":"JWT"}'), + b64url(json.dumps({ + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + }, + }).encode("utf-8")), + b64url(b"signature"), + )) + + primary_token = token_for("acct-primary") + exhausted_token = token_for("acct-exhausted") fetch_calls = [] load_pool_calls = [] - selected = [] - seen = {} + entries_called = [] + seen = [] agent_mod = types.ModuleType("agent") agent_mod.__path__ = [] @@ -310,25 +314,41 @@ def test_codex_account_usage_subprocess_falls_back_to_credential_pool(monkeypatc return None class FakePool: + def entries(self): + entries_called.append(True) + return [ + SimpleNamespace( + label="Team primary", + runtime_api_key=primary_token, + runtime_base_url="https://chatgpt.com/backend-api/codex", + last_status=None, + ), + SimpleNamespace( + label="Plus backup", + runtime_api_key=exhausted_token, + runtime_base_url="https://chatgpt.com/backend-api/codex", + last_status="exhausted", + ), + ] + def select(self): - selected.append(True) - return SimpleNamespace( - runtime_api_key=token, - runtime_base_url="https://chatgpt.com/backend-api/codex", - ) + raise AssertionError("quota display must not rotate credential_pool selection") def fake_load_pool(provider): load_pool_calls.append(provider) return FakePool() def fake_urlopen(req, timeout): - seen["url"] = req.full_url - seen["timeout"] = timeout - seen["headers"] = {key.lower(): value for key, value in req.header_items()} + headers = {key.lower(): value for key, value in req.header_items()} + seen.append({ + "url": req.full_url, + "timeout": timeout, + "headers": headers, + }) payload = { - "plan_type": "pro", + "plan_type": "pro" if headers.get("chatgpt-account-id") == "acct-primary" else "plus", "rate_limit": { - "primary_window": {"used_percent": 15, "reset_at": 1_900_000_000}, + "primary_window": {"used_percent": 15 if headers.get("chatgpt-account-id") == "acct-primary" else 95, "reset_at": 1_900_000_000}, "secondary_window": {"used_percent": 40, "reset_at": "2030-03-24T12:30:00Z"}, }, "credits": {"has_credits": True, "balance": 12.5}, @@ -350,24 +370,156 @@ def test_codex_account_usage_subprocess_falls_back_to_credential_pool(monkeypatc assert fetch_calls == [("openai-codex", None, None)] assert load_pool_calls == ["openai-codex"] - assert selected == [True] - assert seen["url"] == "https://chatgpt.com/backend-api/wham/usage" - assert seen["timeout"] == 15.0 - headers = seen["headers"] - assert headers["authorization"] == f"Bearer {token}" - assert headers["accept"] == "application/json" - assert headers["originator"] == "codex_cli_rs" - assert headers["user-agent"].startswith("codex_cli_rs/") - assert headers["chatgpt-account-id"] == "acct-test-123" + assert entries_called == [True] + assert [call["url"] for call in seen] == [ + "https://chatgpt.com/backend-api/wham/usage", + "https://chatgpt.com/backend-api/wham/usage", + ] + assert [call["timeout"] for call in seen] == [4.0, 4.0] + assert seen[0]["headers"]["authorization"] == f"Bearer {primary_token}" + assert seen[0]["headers"]["chatgpt-account-id"] == "acct-primary" + assert seen[1]["headers"]["chatgpt-account-id"] == "acct-exhausted" assert snapshot["provider"] == "openai-codex" - assert snapshot["source"] == "usage_api" - assert snapshot["plan"] == "Pro" + assert snapshot["source"] == "usage_api_pool" assert snapshot["windows"][0]["label"] == "Session" - assert snapshot["windows"][0]["used_percent"] == 15.0 - assert snapshot["windows"][1]["label"] == "Weekly" - assert snapshot["details"] == ["Credits balance: $12.50"] + assert snapshot["windows"][0]["used_percent"] == 15 + assert snapshot["details"] == ["1/2 credentials available", "1 exhausted", "Plans: Pro, Plus"] assert snapshot["available"] is True - assert token not in output + assert snapshot["pool"] == { + "total_credentials": 2, + "queried_credentials": 2, + "available_credentials": 1, + "exhausted_credentials": 1, + "failed_credentials": 0, + "plans": ["Pro", "Plus"], + "next_reset_at": "2030-03-17T17:46:40Z", + "best_remaining_by_window": [ + { + "label": "Session", + "remaining_percent": 85.0, + "used_percent": 15.0, + "reset_at": "2030-03-17T17:46:40Z", + "detail": None, + "credential_label": "Team primary", + }, + { + "label": "Weekly", + "remaining_percent": 60.0, + "used_percent": 40.0, + "reset_at": "2030-03-24T12:30:00Z", + "detail": None, + "credential_label": "Team primary", + }, + ], + "credentials": [ + { + "label": "Team primary", + "status": "available", + "plan": "Pro", + "windows": [ + { + "label": "Session", + "used_percent": 15.0, + "remaining_percent": 85.0, + "reset_at": "2030-03-17T17:46:40Z", + "detail": None, + }, + { + "label": "Weekly", + "used_percent": 40.0, + "remaining_percent": 60.0, + "reset_at": "2030-03-24T12:30:00Z", + "detail": None, + }, + ], + "details": ["Credits balance: $12.50"], + "unavailable_reason": None, + "fetched_at": snapshot["pool"]["credentials"][0]["fetched_at"], + }, + { + "label": "Plus backup", + "status": "exhausted", + "plan": "Plus", + "windows": [ + { + "label": "Session", + "used_percent": 95.0, + "remaining_percent": 5.0, + "reset_at": "2030-03-17T17:46:40Z", + "detail": None, + }, + { + "label": "Weekly", + "used_percent": 40.0, + "remaining_percent": 60.0, + "reset_at": "2030-03-24T12:30:00Z", + "detail": None, + }, + ], + "details": ["Credits balance: $12.50"], + "unavailable_reason": None, + "fetched_at": snapshot["pool"]["credentials"][1]["fetched_at"], + }, + ], + } + assert primary_token not in output + assert exhausted_token not in output + + + +def test_codex_account_usage_subprocess_sanitizes_pool_entry_errors(monkeypatch, capsys): + """Pool per-entry failures must not leak bearer/JWT-like exception text.""" + import api.providers as providers + + fetch_calls = [] + agent_mod = types.ModuleType("agent") + agent_mod.__path__ = [] + account_usage_mod = types.ModuleType("agent.account_usage") + credential_pool_mod = types.ModuleType("agent.credential_pool") + + def fake_fetch_account_usage(provider, *, base_url=None, api_key=None): + fetch_calls.append((provider, base_url, api_key)) + return None + + class FakePool: + def entries(self): + return [ + SimpleNamespace( + label="Bad token", + runtime_api_key="header.payload.signature", + runtime_base_url="https://chatgpt.com/backend-api/codex", + last_status=None, + ), + ] + + def select(self): + raise AssertionError("quota display must not rotate credential_pool selection") + + def fake_load_pool(provider): + return FakePool() + + def fake_urlopen(_req, timeout): + raise RuntimeError("Bearer eyJsecret-token-like-value should not leak") + + account_usage_mod.fetch_account_usage = fake_fetch_account_usage + credential_pool_mod.load_pool = fake_load_pool + monkeypatch.setitem(sys.modules, "agent", agent_mod) + monkeypatch.setitem(sys.modules, "agent.account_usage", account_usage_mod) + monkeypatch.setitem(sys.modules, "agent.credential_pool", credential_pool_mod) + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(sys, "argv", ["quota-probe", "openai-codex", ""]) + + exec(providers._ACCOUNT_USAGE_SUBPROCESS_CODE, {"__name__": "__main__"}) + + output = capsys.readouterr().out.strip() + snapshot = json.loads(output) + + assert fetch_calls == [("openai-codex", None, None)] + assert snapshot["available"] is False + assert snapshot["pool"]["failed_credentials"] == 1 + assert snapshot["pool"]["credentials"][0]["unavailable_reason"] == "Usage unavailable for this credential." + assert "eyJsecret" not in output + assert "Bearer" not in output def test_codex_account_usage_subprocess_keeps_legacy_reason_when_pool_misses(monkeypatch, capsys): @@ -426,6 +578,40 @@ def test_codex_account_usage_subprocess_keeps_legacy_reason_when_pool_misses(mon assert snapshot["fetched_at"] == "2030-03-17T12:30:00Z" +def test_account_usage_pool_payload_round_trips_to_provider_quota_status(): + """Parent process serialization must preserve pooled credential summaries.""" + import api.providers as providers + + payload = { + "provider": "openai-codex", + "source": "usage_api_pool", + "title": "Account limits", + "plan": None, + "windows": [ + {"label": "Session", "used_percent": 25, "reset_at": "2030-03-17T17:30:00Z", "detail": "Best of 2"}, + ], + "details": ["2/3 credentials available"], + "available": True, + "unavailable_reason": None, + "fetched_at": "2030-03-17T12:30:00Z", + "pool": { + "total_credentials": 3, + "available_credentials": 2, + "exhausted_credentials": 1, + "failed_credentials": 0, + "credentials": [ + {"label": "Credential 1", "status": "available", "windows": []}, + ], + }, + } + + snapshot = providers._account_usage_payload_to_snapshot(payload) + serialized = providers._serialize_account_usage_snapshot(snapshot) + + assert serialized["windows"][0]["remaining_percent"] == 75.0 + assert serialized["pool"] == payload["pool"] + + def test_anthropic_oauth_usage_unavailable_reason_is_reported(monkeypatch, tmp_path): """Hermes Agent can report why account limits are not available.""" monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) @@ -486,6 +672,71 @@ def test_account_usage_profile_env_is_child_scoped(monkeypatch, tmp_path): assert os.environ["ANTHROPIC_API_KEY"] == "process-key" +def test_account_usage_profile_fetch_uses_short_lived_cache(monkeypatch, tmp_path): + """Repeated Settings refreshes should not re-query pooled account usage immediately.""" + import api.providers as providers + + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + old_cfg, old_mtime = _with_config(model={"provider": "openai-codex"}) + providers._account_usage_status_cache.clear() + calls = [] + snapshot = SimpleNamespace( + provider="openai-codex", + source="usage_api_pool", + title="Account limits", + plan=None, + windows=(), + details=(), + available=True, + unavailable_reason=None, + fetched_at=datetime(2030, 3, 17, 12, 30, tzinfo=timezone.utc), + pool={"total_credentials": 1, "credentials": []}, + ) + + def fake_fetch(provider, home, api_key=None): + calls.append((provider, str(home), api_key)) + return snapshot + + monkeypatch.setattr(providers, "_agent_fetch_account_usage_for_home", fake_fetch) + try: + first = providers._fetch_account_usage_with_profile_context("openai-codex") + second = providers._fetch_account_usage_with_profile_context("openai-codex") + finally: + providers._account_usage_status_cache.clear() + _restore_config(old_cfg, old_mtime) + + assert first is snapshot + assert second is snapshot + assert calls == [("openai-codex", str(tmp_path), None)] + + + +def test_account_usage_profile_fetch_caches_none_results(monkeypatch, tmp_path): + """Known-empty/unavailable account usage should be cached, not re-probed each refresh.""" + import api.providers as providers + + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + old_cfg, old_mtime = _with_config(model={"provider": "openai-codex"}) + providers._account_usage_status_cache.clear() + calls = [] + + def fake_fetch(provider, home, api_key=None): + calls.append((provider, str(home), api_key)) + return None + + monkeypatch.setattr(providers, "_agent_fetch_account_usage_for_home", fake_fetch) + try: + first = providers._fetch_account_usage_with_profile_context("openai-codex") + second = providers._fetch_account_usage_with_profile_context("openai-codex") + finally: + providers._account_usage_status_cache.clear() + _restore_config(old_cfg, old_mtime) + + assert first is None + assert second is None + assert calls == [("openai-codex", str(tmp_path), None)] + + def test_account_usage_profile_fetches_can_overlap_for_different_homes(monkeypatch, tmp_path): """Different profile quota fetches should not serialize on cron's global lock.""" import api.providers as providers @@ -531,6 +782,81 @@ def test_account_usage_profile_fetches_can_overlap_for_different_homes(monkeypat assert [kind for kind, _home in events[:2]] == ["enter", "enter"] +def test_openai_api_key_detection_ignores_codex_oauth_jwt(monkeypatch, tmp_path): + """A Codex OAuth JWT in OPENAI_API_KEY should not show a bare OpenAI card.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + def b64url(payload: bytes) -> str: + return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") + + codex_token = ".".join(( + b64url(b'{"alg":"none","typ":"JWT"}'), + b64url(json.dumps({ + "https://api.openai.com/auth": {"chatgpt_account_id": "acct-codex"}, + }).encode("utf-8")), + b64url(b"signature"), + )) + (tmp_path / ".env").write_text(f"OPENAI_API_KEY={codex_token}\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openai"}) + + import api.providers as providers + try: + assert providers._provider_has_key("openai") is False + assert providers._get_provider_api_key("openai") is None + provider_ids = [p["id"] for p in providers.get_providers()["providers"]] + finally: + _restore_config(old_cfg, old_mtime) + + assert "openai" not in provider_ids + assert "openai-codex" in provider_ids + assert codex_token not in repr(provider_ids) + + +def test_openai_api_key_detection_still_accepts_real_api_keys(monkeypatch, tmp_path): + """Filtering Codex OAuth tokens must not hide real OpenAI API keys.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENAI_API_KEY=sk-test-real-openai-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openai"}) + + import api.providers as providers + try: + assert providers._provider_has_key("openai") is True + assert providers._get_provider_api_key("openai") == "sk-test-real-openai-key" + finally: + _restore_config(old_cfg, old_mtime) + + + +def test_openai_api_key_detection_falls_through_after_codex_jwt_config_value(monkeypatch, tmp_path): + """A filtered OpenAI config value should not mask a later real API key source.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + def b64url(payload: bytes) -> str: + return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") + + codex_token = ".".join(( + b64url(b'{"alg":"none","typ":"JWT"}'), + b64url(json.dumps({ + "https://api.openai.com/auth": {"chatgpt_account_id": "acct-codex"}, + }).encode("utf-8")), + b64url(b"signature"), + )) + old_cfg, old_mtime = _with_config( + model={"provider": "openai", "api_key": codex_token}, + providers={"openai": {"api_key": "sk-config-openai-key"}}, + ) + + import api.providers as providers + try: + assert providers._provider_has_key("openai") is True + assert providers._get_provider_api_key("openai") == "sk-config-openai-key" + finally: + _restore_config(old_cfg, old_mtime) + + def test_provider_quota_route_is_registered(): """The backend must expose a route for the UI to poll quota status.""" routes = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") @@ -549,6 +875,14 @@ def test_provider_quota_card_is_rendered_in_providers_panel(): assert "account_limits" in panels assert "remaining_percent" in panels assert "provider-quota-details" in panels + assert "Credential pool" in panels + assert "provider-quota-pool-row" in panels + assert "_buildProviderQuotaPoolBreakdown" in panels + assert "_providerQuotaPoolShouldDefaultOpen" in panels + assert "hermes-provider-quota-pool-open" in panels + assert "count>0&&count<=3" in panels + assert "status.status==='available'||accountLimits.pool" in panels + assert "provider-quota-window-detail" in panels assert "5-hour limit" in panels @@ -581,6 +915,10 @@ def test_provider_quota_styles_exist(): ".provider-quota-actions", ".provider-quota-refresh", ".provider-quota-checked", + ".provider-quota-pool", + ".provider-quota-pool-row", + ".provider-quota-pool-window", + ".provider-quota-window-detail", ): assert token in css