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=`
+
+ ${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