Stage 300: PR #1671 — feat: add active provider quota status by @Michaelyklam

This commit is contained in:
test
2026-05-05 02:27:23 +00:00
6 changed files with 441 additions and 1 deletions
+185
View File
@@ -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
+5 -1
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+40
View File
@@ -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'&&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){
const card=document.createElement('div');
card.className='provider-card';
+20
View File
@@ -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;
+191
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