feat: add active provider quota status
This commit is contained in:
parent
fcc83284e3
commit
22df075b8a
6 changed files with 441 additions and 1 deletions
185
api/providers.py
185
api/providers.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
BIN
docs/pr-media/706/openrouter-quota-card.png
Normal file
BIN
docs/pr-media/706/openrouter-quota-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
|
|
@ -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'&"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){
|
function _buildProviderCard(p){
|
||||||
const card=document.createElement('div');
|
const card=document.createElement('div');
|
||||||
card.className='provider-card';
|
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),
|
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;
|
||||||
|
|
|
||||||
191
tests/test_provider_quota_status.py
Normal file
191
tests/test_provider_quota_status.py
Normal 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
|
||||||
Loading…
Reference in a new issue