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 00000000..ed0b7500 Binary files /dev/null and b/docs/pr-media/706/openrouter-quota-card.png differ 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