feat: add active provider quota status

This commit is contained in:
Michael Lam 2026-05-04 17:04:18 -07:00 committed by test
parent fcc83284e3
commit 22df075b8a
6 changed files with 441 additions and 1 deletions

View file

@ -7,8 +7,11 @@ multi-provider support).
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -23,6 +26,9 @@ from api.config import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key"
_PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0
# SECTION: Provider ↔ env var mapping # SECTION: Provider ↔ env var mapping
# Maps canonical provider slug → env var name for API key. # Maps canonical provider slug → env var name for API key.
@ -268,6 +274,185 @@ def _provider_has_key(provider_id: str) -> bool:
return False 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: def _provider_is_oauth(provider_id: str) -> bool:
"""Check whether a provider uses OAuth/token flows (managed by CLI).""" """Check whether a provider uses OAuth/token flows (managed by CLI)."""
return provider_id in _OAUTH_PROVIDERS return provider_id in _OAUTH_PROVIDERS

View file

@ -1358,7 +1358,7 @@ from api.workspace import (
) )
from api.upload import handle_upload, handle_upload_extract, handle_transcribe from api.upload import handle_upload, handle_upload_extract, handle_transcribe
from api.streaming import _sse, _run_agent_streaming, cancel_stream 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 ( from api.onboarding import (
apply_onboarding_setup, apply_onboarding_setup,
get_onboarding_status, get_onboarding_status,
@ -2515,6 +2515,10 @@ def handle_get(handler, parsed) -> bool:
# ── Plugins/hooks visibility (read-only, no callback/source internals) ── # ── Plugins/hooks visibility (read-only, no callback/source internals) ──
if parsed.path == "/api/plugins": if parsed.path == "/api/plugins":
return _handle_plugins(handler, parsed) 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": if parsed.path == "/api/settings":
settings = load_settings() settings = load_settings()

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View file

@ -4635,9 +4635,12 @@ async function loadProvidersPanel(){
if(!list) return; if(!list) return;
try{ try{
const data=await api('/api/providers'); 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); const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth);
list.innerHTML=''; list.innerHTML='';
_providerCardEls.clear(); _providerCardEls.clear();
const quotaCard=_buildProviderQuotaCard(quota);
if(quotaCard) list.appendChild(quotaCard);
if(providers.length===0){ if(providers.length===0){
list.style.display='none'; list.style.display='none';
if(empty) empty.style.display=''; 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'&&quota){
body=`
<div class="provider-quota-metric"><span>Remaining</span><strong>${esc(_formatProviderQuotaMoney(quota.limit_remaining))}</strong></div>
<div class="provider-quota-metric"><span>Used</span><strong>${esc(_formatProviderQuotaMoney(quota.usage))}</strong></div>
<div class="provider-quota-metric"><span>Limit</span><strong>${esc(_formatProviderQuotaMoney(quota.limit))}</strong></div>
`;
}else{
body=`<div class="provider-quota-message">${esc(status.message||'Quota status unavailable')}</div>`;
}
card.innerHTML=`
<div class="provider-quota-header">
<div>
<div class="provider-quota-title">Active provider quota</div>
<div class="provider-quota-subtitle">${esc(provider)}</div>
</div>
<span class="provider-quota-badge">${esc(state.replace(/_/g,' '))}</span>
</div>
<div class="provider-quota-body">${body}</div>
`;
return card;
}
function _buildProviderCard(p){ function _buildProviderCard(p){
const card=document.createElement('div'); const card=document.createElement('div');
card.className='provider-card'; card.className='provider-card';

View file

@ -2331,6 +2331,26 @@ main.main.showing-logs > #mainLogs{display:flex;}
Matches hermes-desktop LLM Providers panel. Card uses --sidebar (surface-1), Matches hermes-desktop LLM Providers panel. Card uses --sidebar (surface-1),
hover rows use --surface (surface-2). Body divider uses a subtle tint. */ hover rows use --surface (surface-2). Body divider uses a subtle tint. */
#providersList{gap:12px;} #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{ .provider-card{
border:1px solid var(--border); border:1px solid var(--border);
border-radius:12px; border-radius:12px;

View file

@ -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