mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
feat: add active provider quota status
This commit is contained in:
@@ -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
@@ -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 |
@@ -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=`
|
||||
<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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user