From 22df075b8a7cc00e07ada4c38c4a6cc16bf113e1 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 4 May 2026 17:04:18 -0700 Subject: [PATCH] feat: add active provider quota status --- api/providers.py | 185 +++++++++++++++++++ api/routes.py | 6 +- docs/pr-media/706/openrouter-quota-card.png | Bin 0 -> 60014 bytes static/panels.js | 40 ++++ static/style.css | 20 ++ tests/test_provider_quota_status.py | 191 ++++++++++++++++++++ 6 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 docs/pr-media/706/openrouter-quota-card.png create mode 100644 tests/test_provider_quota_status.py diff --git a/api/providers.py b/api/providers.py index 5e9ffbfb..583670b3 100644 --- a/api/providers.py +++ b/api/providers.py @@ -7,8 +7,11 @@ multi-provider support). from __future__ import annotations +import json import logging import os +import urllib.error +import urllib.request from pathlib import Path from typing import Any @@ -23,6 +26,9 @@ from api.config import ( logger = logging.getLogger(__name__) +_OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key" +_PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0 + # SECTION: Provider ↔ env var mapping # Maps canonical provider slug → env var name for API key. @@ -268,6 +274,185 @@ def _provider_has_key(provider_id: str) -> bool: return False +def _get_provider_api_key(provider_id: str) -> str | None: + """Return a configured provider API key without exposing it to callers.""" + provider_id = (provider_id or "").strip().lower() + env_var = _PROVIDER_ENV_VAR.get(provider_id) + 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 + 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 + + 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: + return model_key + + providers_cfg = cfg.get("providers", {}) + if isinstance(providers_cfg, dict): + 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: + return provider_key + + custom_providers = cfg.get("custom_providers", []) + if isinstance(custom_providers, list): + for cp in custom_providers: + if not isinstance(cp, dict): + continue + cp_name = str(cp.get("name") or "").strip().lower().replace(" ", "-") + if f"custom:{cp_name}" == provider_id or str(cp.get("name", "")).strip().lower() == provider_id: + 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: + return cp_key + return None + + +def _active_provider_id() -> str | None: + cfg = get_config() + model_cfg = cfg.get("model", {}) + if not isinstance(model_cfg, dict): + return None + provider = str(model_cfg.get("provider") or "").strip().lower() + return provider or None + + +def _quota_number(value: Any) -> int | float | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + return value + try: + text = str(value).strip() + if not text: + return None + number = float(text) + return int(number) if number.is_integer() else number + except (TypeError, ValueError): + return None + + +def _sanitize_openrouter_quota(payload: Any) -> dict[str, int | float | None]: + if isinstance(payload, dict) and isinstance(payload.get("data"), dict): + payload = payload["data"] + if not isinstance(payload, dict): + payload = {} + return { + "limit_remaining": _quota_number(payload.get("limit_remaining")), + "usage": _quota_number(payload.get("usage")), + "limit": _quota_number(payload.get("limit")), + } + + +def get_provider_quota(provider_id: str | None = None) -> dict[str, Any]: + """Return sanitized quota/rate-limit status for the active provider. + + Issue #706 starts conservatively with OpenRouter's documented key endpoint. + OpenAI/Anthropic only expose per-call headers; until the WebUI captures those + response headers, report a clear unsupported/follow-up state rather than + inventing stale or guessed quota numbers. + """ + provider = (provider_id or _active_provider_id() or "").strip().lower() + if not provider: + return { + "ok": False, + "provider": None, + "display_name": None, + "supported": False, + "status": "unavailable", + "quota": None, + "message": "No active provider is configured.", + } + + display_name = _PROVIDER_DISPLAY.get(provider, provider.replace("-", " ").title()) + if provider != "openrouter": + detail = "OpenAI/Anthropic rate-limit headers are a follow-up once WebUI captures provider response metadata." + return { + "ok": False, + "provider": provider, + "display_name": display_name, + "supported": False, + "status": "unsupported", + "quota": None, + "message": f"Quota status is not available for {display_name}. {detail}", + } + + api_key = _get_provider_api_key("openrouter") + if not api_key: + return { + "ok": False, + "provider": "openrouter", + "display_name": display_name, + "supported": True, + "status": "no_key", + "quota": None, + "message": "OpenRouter quota status needs an OPENROUTER_API_KEY configured on the server.", + } + + req = urllib.request.Request( + _OPENROUTER_KEY_URL, + headers={ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=_PROVIDER_QUOTA_TIMEOUT_SECONDS) as resp: + raw = resp.read() + payload = json.loads(raw.decode("utf-8")) if isinstance(raw, (bytes, bytearray)) else json.loads(raw) + quota = _sanitize_openrouter_quota(payload) + return { + "ok": True, + "provider": "openrouter", + "display_name": display_name, + "supported": True, + "status": "available", + "label": "OpenRouter credits", + "quota": quota, + "message": "OpenRouter quota status loaded.", + } + except urllib.error.HTTPError as exc: + status = "invalid_key" if exc.code in (401, 403) else "unavailable" + message = ( + "OpenRouter rejected the configured API key." + if status == "invalid_key" + else "OpenRouter quota status is temporarily unavailable." + ) + return { + "ok": False, + "provider": "openrouter", + "display_name": display_name, + "supported": True, + "status": status, + "quota": None, + "message": message, + } + except (TimeoutError, urllib.error.URLError, json.JSONDecodeError, OSError, ValueError): + return { + "ok": False, + "provider": "openrouter", + "display_name": display_name, + "supported": True, + "status": "unavailable", + "quota": None, + "message": "OpenRouter quota status is temporarily unavailable.", + } + + def _provider_is_oauth(provider_id: str) -> bool: """Check whether a provider uses OAuth/token flows (managed by CLI).""" return provider_id in _OAUTH_PROVIDERS diff --git a/api/routes.py b/api/routes.py index 85bb6627..831b41be 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1358,7 +1358,7 @@ from api.workspace import ( ) from api.upload import handle_upload, handle_upload_extract, handle_transcribe from api.streaming import _sse, _run_agent_streaming, cancel_stream -from api.providers import get_providers, set_provider_key, remove_provider_key +from api.providers import get_providers, get_provider_quota, set_provider_key, remove_provider_key from api.onboarding import ( apply_onboarding_setup, get_onboarding_status, @@ -2515,6 +2515,10 @@ def handle_get(handler, parsed) -> bool: # ── Plugins/hooks visibility (read-only, no callback/source internals) ── if parsed.path == "/api/plugins": return _handle_plugins(handler, parsed) + if parsed.path == "/api/provider/quota": + query = parse_qs(parsed.query) + provider_id = (query.get("provider", [""])[0] or None) + return j(handler, get_provider_quota(provider_id)) if parsed.path == "/api/settings": settings = load_settings() diff --git a/docs/pr-media/706/openrouter-quota-card.png b/docs/pr-media/706/openrouter-quota-card.png new file mode 100644 index 0000000000000000000000000000000000000000..ed0b7500fad7edd0f48ee6003cd8c01a67329aae GIT binary patch literal 60014 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 literal 0 HcmV?d00001 diff --git a/static/panels.js b/static/panels.js index ea5ddf3f..ea9a30aa 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4635,9 +4635,12 @@ async function loadProvidersPanel(){ if(!list) return; try{ const data=await api('/api/providers'); + const quota=await api('/api/provider/quota').catch(e=>({ok:false,status:'unavailable',quota:null,message:e.message||'Quota status unavailable'})); const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth); list.innerHTML=''; _providerCardEls.clear(); + const quotaCard=_buildProviderQuotaCard(quota); + if(quotaCard) list.appendChild(quotaCard); if(providers.length===0){ list.style.display='none'; if(empty) empty.style.display=''; @@ -4653,6 +4656,43 @@ async function loadProvidersPanel(){ } } +function _formatProviderQuotaMoney(value){ + if(value===null||value===undefined||value==='') return '—'; + const n=Number(value); + if(!Number.isFinite(n)) return '—'; + return '$'+n.toFixed(2); +} + +function _buildProviderQuotaCard(status){ + if(!status) return null; + const card=document.createElement('div'); + const state=(status.status||'unavailable').replace(/[^a-z0-9_-]/gi,'').toLowerCase()||'unavailable'; + card.className='provider-quota-card provider-quota-card-'+state; + const provider=status.display_name||status.provider||'Active provider'; + const quota=status.quota||{}; + let body=''; + if(status.status==='available'&"a){ + body=` +
Remaining${esc(_formatProviderQuotaMoney(quota.limit_remaining))}
+
Used${esc(_formatProviderQuotaMoney(quota.usage))}
+
Limit${esc(_formatProviderQuotaMoney(quota.limit))}
+ `; + }else{ + body=`
${esc(status.message||'Quota status unavailable')}
`; + } + card.innerHTML=` +
+
+
Active provider quota
+
${esc(provider)}
+
+ ${esc(state.replace(/_/g,' '))} +
+
${body}
+ `; + return card; +} + function _buildProviderCard(p){ const card=document.createElement('div'); card.className='provider-card'; diff --git a/static/style.css b/static/style.css index 3022b147..504bf6c3 100644 --- a/static/style.css +++ b/static/style.css @@ -2331,6 +2331,26 @@ main.main.showing-logs > #mainLogs{display:flex;} Matches hermes-desktop LLM Providers panel. Card uses --sidebar (surface-1), hover rows use --surface (surface-2). Body divider uses a subtle tint. */ #providersList{gap:12px;} +.provider-quota-card{ + border:1px solid var(--border); + border-radius:12px; + background:linear-gradient(180deg,var(--surface),var(--sidebar)); + padding:12px 16px; + margin-bottom:12px; +} +.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;} +.provider-quota-subtitle{font-size:11px;color:var(--muted);line-height:1.3;margin-top:2px;} +.provider-quota-badge{font-size:10.5px;font-weight:650;text-transform:capitalize;padding:2px 8px;border-radius:999px;background:var(--accent-bg);color:var(--accent-text);white-space:nowrap;} +.provider-quota-body{display:flex;flex-wrap:wrap;gap:8px;} +.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-message{font-size:12px;color:var(--muted);line-height:1.45;} +.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);} .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 new file mode 100644 index 00000000..35951ed4 --- /dev/null +++ b/tests/test_provider_quota_status.py @@ -0,0 +1,191 @@ +"""Regression coverage for active-provider quota status (#706).""" + +from __future__ import annotations + +import json +import urllib.error +from io import BytesIO +from pathlib import Path + +import api.config as config +import api.profiles as profiles + +ROOT = Path(__file__).resolve().parents[1] + + +class _FakeResponse: + def __init__(self, payload: bytes): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._payload + + +def _with_config(model=None, providers=None): + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + config.cfg.clear() + config.cfg["model"] = model or {} + if providers is not None: + config.cfg["providers"] = providers + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + return old_cfg, old_mtime + + +def _restore_config(old_cfg, old_mtime): + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + + +def test_openrouter_quota_fetches_key_endpoint_and_sanitizes_response(monkeypatch, tmp_path): + """OpenRouter's documented key endpoint should be called server-side only.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-openrouter-key-private\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + seen = {} + + def fake_urlopen(req, timeout): + seen["url"] = req.full_url + seen["timeout"] = timeout + seen["authorization"] = req.headers.get("Authorization") + payload = {"data": {"limit_remaining": "12.5", "usage": 3, "limit": 20, "key": "must-not-leak"}} + return _FakeResponse(json.dumps(payload).encode("utf-8")) + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + try: + result = providers.get_provider_quota() + finally: + _restore_config(old_cfg, old_mtime) + + assert seen == { + "url": "https://openrouter.ai/api/v1/key", + "timeout": 3.0, + "authorization": "Bearer test-openrouter-key-private", + } + assert result == { + "ok": True, + "provider": "openrouter", + "display_name": "OpenRouter", + "supported": True, + "status": "available", + "label": "OpenRouter credits", + "quota": {"limit_remaining": 12.5, "usage": 3, "limit": 20}, + "message": "OpenRouter quota status loaded.", + } + assert "test-openrouter-key-private" not in repr(result) + assert "must-not-leak" not in repr(result) + + +def test_openrouter_quota_no_key_returns_safe_no_key_without_network(monkeypatch, tmp_path): + """No-key state must not call OpenRouter or leak environment details.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + def explode(*_args, **_kwargs): + raise AssertionError("quota lookup should not call the network without a key") + + monkeypatch.setattr(providers.urllib.request, "urlopen", explode) + try: + result = providers.get_provider_quota() + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is False + assert result["provider"] == "openrouter" + assert result["supported"] is True + assert result["status"] == "no_key" + assert result["quota"] is None + assert "OPENROUTER_API_KEY" in result["message"] + + +def test_openrouter_quota_invalid_key_and_timeout_are_sanitized(monkeypatch, tmp_path): + """Invalid-key and timeout/error paths should expose statuses, not secrets.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-openrouter-key-private\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + req = providers.urllib.request.Request("https://openrouter.ai/api/v1/key") + invalid = urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, BytesIO(b"secret body")) + errors = [invalid, TimeoutError("slow secret")] + + try: + for expected in ("invalid_key", "unavailable"): + def fake_urlopen(_req, timeout=None, *, _err=errors.pop(0)): + raise _err + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + result = providers.get_provider_quota("openrouter") + assert result["ok"] is False + assert result["status"] == expected + assert result["quota"] is None + assert "test-openrouter-key-private" not in repr(result) + assert "secret" not in repr(result).lower() + finally: + _restore_config(old_cfg, old_mtime) + + +def test_unsupported_provider_reports_followup_state(monkeypatch, tmp_path): + """Providers without safe quota APIs should return a clear unsupported state.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + old_cfg, old_mtime = _with_config(model={"provider": "openai"}) + + import api.providers as providers + try: + result = providers.get_provider_quota() + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is False + assert result["provider"] == "openai" + assert result["supported"] is False + assert result["status"] == "unsupported" + assert result["quota"] is None + assert "follow-up" in result["message"] + + +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") + assert 'parsed.path == "/api/provider/quota"' in routes + assert "get_provider_quota(provider_id)" in routes + + +def test_provider_quota_card_is_rendered_in_providers_panel(): + """The Providers panel should show active provider quota/status before cards.""" + panels = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") + assert "api('/api/provider/quota')" in panels + assert "function _buildProviderQuotaCard" in panels + assert "Active provider quota" in panels + assert "provider-quota-card" in panels + + +def test_provider_quota_styles_exist(): + """Quota UI should have visible supported/unavailable/invalid states.""" + css = (ROOT / "static" / "style.css").read_text(encoding="utf-8") + for token in ( + ".provider-quota-card", + ".provider-quota-metric", + ".provider-quota-card-available", + ".provider-quota-card-no_key", + ".provider-quota-card-invalid_key", + ): + assert token in css