mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-22 10:20:14 +00:00
83de9d0cf0
Opus stage-338 review SHOULD-FIX: silent drop at api/providers.py:1049 was diagnostically opaque. logger.warning() now surfaces the bad config entry so operators can spot misconfigurations. Co-authored-by: Opus advisor <opus-advisor@hermes.local>
1242 lines
50 KiB
Python
1242 lines
50 KiB
Python
"""Hermes Web UI -- provider management endpoints.
|
|
|
|
Provides CRUD operations for configuring provider API keys post-onboarding.
|
|
Closes #586 (allow provider key update) and part of #604 (model picker
|
|
multi-provider support).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from api.config import (
|
|
_PROVIDER_DISPLAY,
|
|
_PROVIDER_MODELS,
|
|
_custom_provider_slug_from_name,
|
|
_get_label_for_model,
|
|
_models_from_live_provider_ids,
|
|
_read_live_provider_model_ids,
|
|
_read_visible_codex_cache_model_ids,
|
|
_save_yaml_config_file,
|
|
get_config,
|
|
invalidate_models_cache,
|
|
reload_config,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _custom_provider_name_matches(provider_id: str, name: object) -> bool:
|
|
"""Return True when *provider_id* refers to a named custom provider."""
|
|
pid = str(provider_id or "").strip().lower()
|
|
raw_name = str(name or "").strip().lower()
|
|
if not pid or not raw_name:
|
|
return False
|
|
slug = _custom_provider_slug_from_name(raw_name)
|
|
candidates = {raw_name, f"custom:{raw_name}"}
|
|
if slug:
|
|
candidates.add(slug)
|
|
return pid in candidates
|
|
|
|
_OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key"
|
|
_PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0
|
|
_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS = 35.0
|
|
_ACCOUNT_USAGE_PROVIDERS = frozenset({"openai-codex", "anthropic"})
|
|
|
|
# Upper bound on simultaneous profile-isolated quota probe subprocesses.
|
|
# Each probe runs a Python child for up to 35 s; capping concurrency prevents
|
|
# resource exhaustion when the UI polls all providers rapidly. The limit is
|
|
# deliberately low (2) since _ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS is
|
|
# already 35 s and probe I/O is lightweight HTTP calls.
|
|
_MAX_CONCURRENT_ACCOUNT_USAGE_PROBES = 2
|
|
|
|
# Parent-death-signal setup: on Linux, arrange for the quota-probe child to
|
|
# receive SIGTERM when the WebUI parent dies (e.g. systemctl restart, OOM kill).
|
|
# This prevents probe children from becoming orphaned zombies that continue
|
|
# calling the provider API indefinitely after the WebUI process is gone.
|
|
# We use prctl(PR_SET_DEATHSIG, SIGTERM) which is standard on modern Linux
|
|
# kernels and available via ctypes (no external C extension needed).
|
|
# If prctl is unavailable (non-Linux, or Linux without prctl support), the
|
|
# probe child exits normally when its parent (WebUI) terminates -- on macOS/
|
|
# Windows this is handled by OS-level process tree cleanup.
|
|
# Portable parent-death-signal bootstrap. On Linux this arranges for the
|
|
# probe child to receive SIGTERM when the WebUI parent dies (systemctl
|
|
# restart, OOM kill, etc.), preventing orphaned zombie probes from continuing
|
|
# to call the provider API indefinitely. Non-Linux platforms (macOS, Windows)
|
|
# rely on OS-level process-tree cleanup instead; this variable is then unused.
|
|
# prctl(PR_SET_DEATHSIG, SIGTERM) is available via ctypes without any C
|
|
# extension — the same technique used throughout the Hermes codebase.
|
|
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP = (
|
|
# fmt: off
|
|
# Lines are written as string literals so this block passes
|
|
# `python3 -m py_compile` cleanly and is safe to include verbatim
|
|
# inside the single argument string passed to `python -c ...`.
|
|
'import sys\n'
|
|
'try:\n'
|
|
' import ctypes, signal\n'
|
|
' libc = ctypes.CDLL(None)\n'
|
|
' libc.prctl(1, signal.SIGTERM) # PR_SET_DEATHSIG=1, SIGTERM=15\n'
|
|
'except Exception:\n'
|
|
' pass\n'
|
|
# fmt: on
|
|
)
|
|
|
|
|
|
# Module-level cap on concurrent quota-probe subprocesses.
|
|
# Lazily created so this module compiles even when threading isn't ready.
|
|
_account_usage_probe_semaphore: threading.BoundedSemaphore | None = None
|
|
|
|
|
|
def _get_account_usage_probe_semaphore() -> threading.BoundedSemaphore:
|
|
global _account_usage_probe_semaphore
|
|
if _account_usage_probe_semaphore is None:
|
|
_account_usage_probe_semaphore = threading.BoundedSemaphore(
|
|
_MAX_CONCURRENT_ACCOUNT_USAGE_PROBES
|
|
)
|
|
return _account_usage_probe_semaphore
|
|
|
|
|
|
# ── preexec_fn: parent-death signal for the probe subprocess ─────────────────
|
|
# On POSIX/Linux, arrange for the child to receive SIGTERM when the WebUI
|
|
# parent dies (systemctl restart, OOM kill, etc.). The parent's bootstrap
|
|
# code (_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP) also covers the grandchild
|
|
# fork inside the child, but this preexec_fn handles the direct child-process
|
|
# case. Returns None on non-POSIX or when prctl is unavailable so that
|
|
# subprocess.run() works on Windows/macOS without changes.
|
|
def _account_usage_preexec_fn() -> None:
|
|
try:
|
|
import ctypes
|
|
libc = ctypes.CDLL(None)
|
|
libc.prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG=1, SIGTERM=15
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
_ACCOUNT_USAGE_SUBPROCESS_CODE = r"""
|
|
import json
|
|
import sys
|
|
|
|
from agent.account_usage import fetch_account_usage
|
|
|
|
|
|
def _iso(value):
|
|
if value in (None, ""):
|
|
return None
|
|
if hasattr(value, "isoformat"):
|
|
text = value.isoformat()
|
|
return text.replace("+00:00", "Z")
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def _snapshot_payload(snapshot):
|
|
if snapshot is None:
|
|
return None
|
|
windows = []
|
|
for window in getattr(snapshot, "windows", ()) or ():
|
|
windows.append({
|
|
"label": str(getattr(window, "label", "") or ""),
|
|
"used_percent": getattr(window, "used_percent", None),
|
|
"reset_at": _iso(getattr(window, "reset_at", None)),
|
|
"detail": getattr(window, "detail", None),
|
|
})
|
|
return {
|
|
"provider": str(getattr(snapshot, "provider", "") or ""),
|
|
"source": str(getattr(snapshot, "source", "") or ""),
|
|
"title": str(getattr(snapshot, "title", "") or ""),
|
|
"plan": getattr(snapshot, "plan", None),
|
|
"windows": windows,
|
|
"details": list(getattr(snapshot, "details", ()) or ()),
|
|
"available": bool(getattr(snapshot, "available", bool(windows))),
|
|
"unavailable_reason": getattr(snapshot, "unavailable_reason", None),
|
|
"fetched_at": _iso(getattr(snapshot, "fetched_at", None)),
|
|
}
|
|
|
|
|
|
provider = sys.argv[1]
|
|
api_key = sys.argv[2] or None
|
|
print(json.dumps(_snapshot_payload(fetch_account_usage(provider, api_key=api_key))))
|
|
"""
|
|
|
|
# SECTION: Provider ↔ env var mapping
|
|
|
|
# Maps canonical provider slug → env var name for API key.
|
|
# Providers not listed here (OAuth/token-flow providers like copilot, nous,
|
|
# openai-codex) cannot have their keys managed from the WebUI.
|
|
_PROVIDER_ENV_VAR: dict[str, str] = {
|
|
"openrouter": "OPENROUTER_API_KEY",
|
|
"anthropic": "ANTHROPIC_API_KEY",
|
|
"openai": "OPENAI_API_KEY",
|
|
"google": "GOOGLE_API_KEY",
|
|
"gemini": "GEMINI_API_KEY",
|
|
"zai": "GLM_API_KEY",
|
|
"kimi-coding": "KIMI_API_KEY",
|
|
"deepseek": "DEEPSEEK_API_KEY",
|
|
"minimax": "MINIMAX_API_KEY",
|
|
"minimax-cn": "MINIMAX_CN_API_KEY",
|
|
"mistralai": "MISTRAL_API_KEY",
|
|
"x-ai": "XAI_API_KEY",
|
|
"xiaomi": "XIAOMI_API_KEY",
|
|
"opencode-zen": "OPENCODE_ZEN_API_KEY",
|
|
"opencode-go": "OPENCODE_GO_API_KEY",
|
|
# NOTE: bare "ollama" (local) deliberately omitted — local Ollama is keyless
|
|
# by default and the runtime in hermes_cli/runtime_provider.py only consumes
|
|
# OLLAMA_API_KEY when the base URL hostname is ollama.com (Ollama Cloud).
|
|
# If we mapped both providers to the same env var, configuring Ollama Cloud
|
|
# would falsely flip the local Ollama card to "API key configured" (#1410).
|
|
# Users who genuinely run an authenticated local Ollama can still set a key
|
|
# via providers.ollama.api_key in config.yaml — that path remains supported
|
|
# by _provider_has_key().
|
|
"ollama-cloud": "OLLAMA_API_KEY",
|
|
# Bare "lmstudio" maps to LM_API_KEY — the canonical env var the agent CLI
|
|
# runtime reads (hermes_cli/auth.py:182, api_key_env_vars=("LM_API_KEY",)).
|
|
# Pre-#1499/#1500 the WebUI used LMSTUDIO_API_KEY here, which made Settings
|
|
# report keys correctly but the agent runtime ignored them — masked in
|
|
# practice by the LMSTUDIO_NOAUTH_PLACEHOLDER for keyless local installs.
|
|
# Aligning to LM_API_KEY makes a configured LM Studio key actually work
|
|
# for chat. The legacy LMSTUDIO_API_KEY name is read by `_provider_has_key`
|
|
# via _PROVIDER_ENV_VAR_ALIASES below so existing users don't see Settings
|
|
# flip to "no key" after upgrading.
|
|
"lmstudio": "LM_API_KEY",
|
|
"nvidia": "NVIDIA_API_KEY",
|
|
}
|
|
|
|
# Read-only legacy env-var aliases. When `_provider_has_key(pid)` looks up its
|
|
# canonical env var name and finds nothing, it also checks any aliases listed
|
|
# here. Onboarding (api/onboarding.py:apply_onboarding_setup) only writes the
|
|
# canonical name. Use this for env vars that were renamed in a past release;
|
|
# add an entry, ship for a few releases, then remove the alias once enough
|
|
# users have upgraded.
|
|
_PROVIDER_ENV_VAR_ALIASES: dict[str, tuple[str, ...]] = {
|
|
# #1500 — agent runtime reads LM_API_KEY (canonical), but WebUI builds
|
|
# ≤ v0.50.272 wrote LMSTUDIO_API_KEY into .env. Keep reading both.
|
|
"lmstudio": ("LMSTUDIO_API_KEY",),
|
|
}
|
|
|
|
# Providers that use OAuth or token flows — their credentials are managed
|
|
# through the Hermes CLI, not via API keys. The WebUI cannot set these.
|
|
_OAUTH_PROVIDERS = frozenset({
|
|
"copilot",
|
|
"copilot-acp",
|
|
"nous",
|
|
"openai-codex",
|
|
"qwen-oauth",
|
|
})
|
|
|
|
# SECTION: Helper functions
|
|
|
|
|
|
def _get_hermes_home() -> Path:
|
|
"""Return the active Hermes home directory."""
|
|
try:
|
|
from api.profiles import get_active_hermes_home
|
|
return get_active_hermes_home()
|
|
except ImportError:
|
|
return Path.home() / ".hermes"
|
|
|
|
|
|
def _load_env_file(env_path: Path) -> dict[str, str]:
|
|
"""Read key=value pairs from a .env file."""
|
|
values: dict[str, str] = {}
|
|
if not env_path.exists():
|
|
return values
|
|
try:
|
|
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
except Exception:
|
|
return {}
|
|
return values
|
|
|
|
|
|
def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
|
|
"""Write key=value pairs to the .env file.
|
|
|
|
Values of ``None`` cause the key to be removed.
|
|
|
|
Preserves comments, blank lines, and original key order (#1164).
|
|
New keys are appended at the end of the file with a blank-line separator.
|
|
|
|
Holds ``_ENV_LOCK`` from ``api.streaming`` for the entire load → modify →
|
|
write cycle to prevent TOCTOU races between concurrent POST /api/providers
|
|
calls (each reading the same file baseline and overwriting the other's key).
|
|
Also serialises os.environ mutations with streaming sessions.
|
|
"""
|
|
from api.streaming import _ENV_LOCK
|
|
import stat as _stat
|
|
|
|
with _ENV_LOCK:
|
|
# ── Read existing lines (preserving comments and blank lines) ──
|
|
existing_lines: list[str] = []
|
|
if env_path.exists():
|
|
try:
|
|
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
except Exception:
|
|
existing_lines = []
|
|
|
|
# Map each existing key to its line index so we can update in-place.
|
|
existing_key_indices: dict[str, int] = {}
|
|
for _i, _raw in enumerate(existing_lines):
|
|
_stripped = _raw.strip()
|
|
if _stripped and not _stripped.startswith("#") and "=" in _stripped:
|
|
_existing_key_indices_key = _stripped.split("=", 1)[0].strip()
|
|
existing_key_indices[_existing_key_indices_key] = _i
|
|
|
|
output_lines = list(existing_lines)
|
|
new_keys: list[str] = []
|
|
|
|
for key, value in updates.items():
|
|
if value is None:
|
|
# Mark the line for removal (None sentinel) and clear env.
|
|
os.environ.pop(key, None)
|
|
if key in existing_key_indices:
|
|
output_lines[existing_key_indices[key]] = None # type: ignore[assignment]
|
|
continue
|
|
clean = str(value).strip()
|
|
if not clean:
|
|
continue
|
|
# Reject embedded newlines/carriage returns to prevent .env injection
|
|
if "\n" in clean or "\r" in clean:
|
|
raise ValueError("API key must not contain newline characters.")
|
|
os.environ[key] = clean
|
|
|
|
if key in existing_key_indices:
|
|
output_lines[existing_key_indices[key]] = f"{key}={clean}"
|
|
else:
|
|
new_keys.append(f"{key}={clean}")
|
|
|
|
# Remove deleted lines (None sentinels)
|
|
output_lines = [l for l in output_lines if l is not None]
|
|
|
|
# Append new keys after a blank-line separator
|
|
if new_keys:
|
|
if output_lines and output_lines[-1].strip() != "":
|
|
output_lines.append("")
|
|
output_lines.extend(new_keys)
|
|
|
|
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
content = "\n".join(output_lines)
|
|
if content:
|
|
content += "\n"
|
|
# Atomic write via tempfile + os.replace so cross-process readers
|
|
# (Telegram bot, CLI) never see a half-truncated file. The shared
|
|
# ``~/.hermes/.env`` is also written by ``hermes_cli.config.save_env_value``
|
|
# using the same atomic pattern; matching it here closes the
|
|
# cross-process leg of #1164 (within-process is covered by _ENV_LOCK).
|
|
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
|
|
import tempfile as _tempfile
|
|
_tmp_fd, _tmp_path = _tempfile.mkstemp(
|
|
dir=str(env_path.parent), prefix=".env_", suffix=".tmp"
|
|
)
|
|
try:
|
|
with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
|
|
_f.write(content)
|
|
_f.flush()
|
|
os.fsync(_f.fileno())
|
|
os.chmod(_tmp_path, _mode) # tighten before rename so readers see 0600
|
|
os.replace(_tmp_path, env_path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(_tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
try:
|
|
env_path.chmod(_mode)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _provider_has_key(provider_id: str) -> bool:
|
|
"""Check whether a provider has a configured API key.
|
|
|
|
Checks (in order):
|
|
1. ``~/.hermes/.env`` for the known env var
|
|
2. ``os.environ`` for the known env var
|
|
3. ``config.yaml → model.api_key`` (only if provider is the active one)
|
|
4. ``config.yaml → providers.<id>.api_key``
|
|
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
|
|
"""
|
|
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 True
|
|
if os.getenv(env_var):
|
|
return True
|
|
# Fall back to legacy env-var aliases (e.g. lmstudio's pre-#1500
|
|
# LMSTUDIO_API_KEY name) so existing users don't lose detection
|
|
# after an env-var rename. See _PROVIDER_ENV_VAR_ALIASES.
|
|
for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or ():
|
|
if env_values.get(alias):
|
|
return True
|
|
if os.getenv(alias):
|
|
return True
|
|
|
|
cfg = get_config()
|
|
# Check model.api_key — only match if this provider is the active one.
|
|
# Previously this checked globally, causing all providers to show
|
|
# "configured" when the active provider had a top-level api_key.
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
|
active_provider = model_cfg.get("provider")
|
|
if active_provider and str(active_provider).strip().lower() == provider_id.lower():
|
|
return True
|
|
# Check providers.<id>.api_key
|
|
providers_cfg = cfg.get("providers", {})
|
|
if isinstance(providers_cfg, dict):
|
|
provider_cfg = providers_cfg.get(provider_id, {})
|
|
if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip():
|
|
return True
|
|
# Check custom_providers
|
|
custom_providers = cfg.get("custom_providers", [])
|
|
if isinstance(custom_providers, list):
|
|
for cp in custom_providers:
|
|
if isinstance(cp, dict):
|
|
if _custom_provider_name_matches(provider_id, cp.get("name")):
|
|
if str(cp.get("api_key") or "").strip():
|
|
return True
|
|
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
|
|
if _custom_provider_name_matches(provider_id, cp.get("name")):
|
|
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 _isoformat_utc(value: Any) -> str | None:
|
|
if value in (None, ""):
|
|
return None
|
|
if isinstance(value, datetime):
|
|
dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def _serialize_account_usage_snapshot(snapshot: Any) -> dict[str, Any] | None:
|
|
if snapshot is None:
|
|
return None
|
|
windows: list[dict[str, Any]] = []
|
|
for window in getattr(snapshot, "windows", ()) or ():
|
|
label = str(getattr(window, "label", "") or "").strip()
|
|
if not label:
|
|
continue
|
|
used_percent = _quota_number(getattr(window, "used_percent", None))
|
|
remaining_percent = None
|
|
if used_percent is not None:
|
|
remaining_percent = max(0.0, min(100.0, 100.0 - float(used_percent)))
|
|
windows.append({
|
|
"label": label,
|
|
"used_percent": used_percent,
|
|
"remaining_percent": remaining_percent,
|
|
"reset_at": _isoformat_utc(getattr(window, "reset_at", None)),
|
|
"detail": str(getattr(window, "detail", "") or "").strip() or None,
|
|
})
|
|
|
|
details = [
|
|
str(detail).strip()
|
|
for detail in (getattr(snapshot, "details", ()) or ())
|
|
if str(detail).strip()
|
|
]
|
|
plan = str(getattr(snapshot, "plan", "") or "").strip() or None
|
|
unavailable_reason = str(getattr(snapshot, "unavailable_reason", "") or "").strip() or None
|
|
return {
|
|
"provider": str(getattr(snapshot, "provider", "") or "").strip() or None,
|
|
"source": str(getattr(snapshot, "source", "") or "").strip() or None,
|
|
"title": str(getattr(snapshot, "title", "") or "").strip() or "Account limits",
|
|
"plan": plan,
|
|
"windows": windows,
|
|
"details": details,
|
|
"available": bool(getattr(snapshot, "available", bool(windows or details))) and not unavailable_reason,
|
|
"unavailable_reason": unavailable_reason,
|
|
"fetched_at": _isoformat_utc(getattr(snapshot, "fetched_at", None)),
|
|
}
|
|
|
|
|
|
def _agent_fetch_account_usage(provider: str, *, base_url: str | None = None, api_key: str | None = None) -> Any:
|
|
from agent.account_usage import fetch_account_usage
|
|
|
|
return fetch_account_usage(provider, base_url=base_url, api_key=api_key)
|
|
|
|
|
|
def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None) -> dict[str, str]:
|
|
env = dict(os.environ)
|
|
env["HERMES_HOME"] = str(Path(home))
|
|
|
|
# Profile .env values should affect only the child quota probe, not the
|
|
# WebUI process-global environment. This is especially important for
|
|
# Anthropic account usage, where the agent resolver reads OAuth/API tokens
|
|
# from environment variables.
|
|
for key, value in _load_env_file(Path(home) / ".env").items():
|
|
if value:
|
|
env[key] = value
|
|
|
|
env_var = _PROVIDER_ENV_VAR.get((provider or "").strip().lower())
|
|
if env_var and api_key:
|
|
env[env_var] = api_key
|
|
|
|
try:
|
|
from api.config import _AGENT_DIR
|
|
except Exception:
|
|
_AGENT_DIR = None
|
|
pythonpath_parts: list[str] = []
|
|
if _AGENT_DIR:
|
|
pythonpath_parts.append(str(_AGENT_DIR))
|
|
existing_pythonpath = env.get("PYTHONPATH", "")
|
|
if existing_pythonpath:
|
|
pythonpath_parts.append(existing_pythonpath)
|
|
if pythonpath_parts:
|
|
env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
|
|
return env
|
|
|
|
|
|
def _account_usage_payload_to_snapshot(payload: Any) -> Any:
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
windows = tuple(
|
|
SimpleNamespace(
|
|
label=window.get("label"),
|
|
used_percent=window.get("used_percent"),
|
|
reset_at=window.get("reset_at"),
|
|
detail=window.get("detail"),
|
|
)
|
|
for window in (payload.get("windows") or ())
|
|
if isinstance(window, dict)
|
|
)
|
|
return SimpleNamespace(
|
|
provider=payload.get("provider"),
|
|
source=payload.get("source"),
|
|
title=payload.get("title"),
|
|
plan=payload.get("plan"),
|
|
windows=windows,
|
|
details=tuple(payload.get("details") or ()),
|
|
available=bool(payload.get("available")),
|
|
unavailable_reason=payload.get("unavailable_reason"),
|
|
fetched_at=payload.get("fetched_at"),
|
|
)
|
|
|
|
|
|
def _agent_fetch_account_usage_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any:
|
|
try:
|
|
from api.config import PYTHON_EXE
|
|
except Exception:
|
|
PYTHON_EXE = sys.executable or "python3"
|
|
|
|
try:
|
|
# On POSIX (Linux/macOS), wire parent-death signal so the child dies
|
|
# cleanly if the WebUI parent terminates. preexec_fn is not safe on
|
|
# Windows, where OS-level process-tree cleanup handles child orphans.
|
|
kwargs: dict[str, Any] = {
|
|
"stdin": subprocess.DEVNULL,
|
|
"stdout": subprocess.PIPE,
|
|
"stderr": subprocess.PIPE,
|
|
"text": True,
|
|
"timeout": _ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS,
|
|
"check": False,
|
|
}
|
|
if hasattr(os, "fork"): # POSIX
|
|
kwargs["preexec_fn"] = _account_usage_preexec_fn
|
|
|
|
proc = subprocess.run(
|
|
[
|
|
PYTHON_EXE, "-c",
|
|
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
|
|
provider,
|
|
api_key or "",
|
|
],
|
|
env=_account_usage_subprocess_env(home, provider, api_key),
|
|
**kwargs,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
logger.debug("Account usage probe for %s timed out", provider)
|
|
return None
|
|
except Exception:
|
|
logger.debug("Account usage probe for %s failed to launch", provider, exc_info=True)
|
|
return None
|
|
|
|
if proc.returncode != 0:
|
|
logger.debug("Account usage probe for %s exited with status %s", provider, proc.returncode)
|
|
return None
|
|
try:
|
|
payload = json.loads((proc.stdout or "").strip() or "null")
|
|
except json.JSONDecodeError:
|
|
logger.debug("Account usage probe for %s returned invalid JSON", provider)
|
|
return None
|
|
return _account_usage_payload_to_snapshot(payload)
|
|
|
|
|
|
def _fetch_account_usage_with_profile_context(provider: str) -> Any:
|
|
"""Fetch account usage for a provider within the active profile context.
|
|
|
|
Concurrency is capped by the module-level BoundedSemaphore so that rapid
|
|
UI polls (e.g. Settings page refresh) cannot exhaust file-descriptors or
|
|
memory by spawning more than _MAX_CONCURRENT_ACCOUNT_USAGE_PROBES probe
|
|
subprocesses simultaneously. Each probe runs up to 35 s.
|
|
|
|
A warm worker-pool (reuse of persistent subprocess handles) is a natural
|
|
follow-up if this first slice proves insufficient in production.
|
|
"""
|
|
home = _get_hermes_home()
|
|
api_key = _get_provider_api_key(provider)
|
|
sem = _get_account_usage_probe_semaphore()
|
|
try:
|
|
with sem:
|
|
return _agent_fetch_account_usage_for_home(
|
|
provider,
|
|
home,
|
|
api_key=api_key,
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to fetch account usage for %s", provider, exc_info=True)
|
|
return None
|
|
|
|
|
|
def _provider_account_usage_status(provider: str, display_name: str) -> dict[str, Any]:
|
|
snapshot = _fetch_account_usage_with_profile_context(provider)
|
|
account_limits = _serialize_account_usage_snapshot(snapshot)
|
|
if account_limits and account_limits.get("available"):
|
|
return {
|
|
"ok": True,
|
|
"provider": provider,
|
|
"display_name": display_name,
|
|
"supported": True,
|
|
"status": "available",
|
|
"label": account_limits.get("title") or "Account limits",
|
|
"quota": None,
|
|
"account_limits": account_limits,
|
|
"message": f"{display_name} account limits loaded.",
|
|
}
|
|
|
|
reason = ""
|
|
if account_limits:
|
|
reason = str(account_limits.get("unavailable_reason") or "").strip()
|
|
message = (
|
|
f"{display_name} account limits are unavailable. {reason}"
|
|
if reason
|
|
else f"{display_name} account limits are unavailable. Confirm provider authentication and try again."
|
|
)
|
|
return {
|
|
"ok": False,
|
|
"provider": provider,
|
|
"display_name": display_name,
|
|
"supported": True,
|
|
"status": "unavailable",
|
|
"quota": None,
|
|
"account_limits": account_limits,
|
|
"message": message,
|
|
}
|
|
|
|
|
|
def get_provider_quota(provider_id: str | None = None) -> dict[str, Any]:
|
|
"""Return sanitized quota/rate-limit status for the active provider.
|
|
|
|
OpenRouter keeps its documented key endpoint. OAuth-backed account usage
|
|
providers reuse Hermes Agent's /usage account-limits abstraction so WebUI
|
|
stays aligned with CLI/Gateway provider semantics.
|
|
"""
|
|
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 in _ACCOUNT_USAGE_PROVIDERS:
|
|
return _provider_account_usage_status(provider, display_name)
|
|
|
|
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
|
|
|
|
|
|
# SECTION: Public API
|
|
|
|
|
|
def get_providers() -> dict[str, Any]:
|
|
"""Return a list of all known providers with their configuration status.
|
|
|
|
Each entry contains:
|
|
- ``id``: canonical provider slug
|
|
- ``display_name``: human-readable name
|
|
- ``has_key``: whether an API key is configured
|
|
- ``configurable``: whether the key can be set from the WebUI
|
|
- ``key_source``: where the key was found (``env_file``, ``env_var``,
|
|
``config_yaml``, ``oauth``, ``none``)
|
|
- ``models``: list of known model IDs for this provider
|
|
"""
|
|
providers = []
|
|
|
|
# Collect all known provider IDs from multiple sources
|
|
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
|
|
|
|
# Also detect providers from config.yaml providers section
|
|
cfg = get_config()
|
|
providers_cfg = cfg.get("providers", {})
|
|
if isinstance(providers_cfg, dict):
|
|
known_ids.update(providers_cfg.keys())
|
|
|
|
# Add OAuth providers even if not in _PROVIDER_DISPLAY
|
|
known_ids.update(_OAUTH_PROVIDERS)
|
|
|
|
for pid in sorted(known_ids):
|
|
display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
|
|
is_oauth = _provider_is_oauth(pid)
|
|
has_key = _provider_has_key(pid)
|
|
|
|
# Determine key source
|
|
key_source = "none"
|
|
auth_error = None
|
|
if is_oauth:
|
|
key_source = "oauth"
|
|
# Check if actually authenticated via hermes_cli.
|
|
# IMPORTANT: do not unconditionally overwrite has_key from _provider_has_key().
|
|
# A token in config.yaml is a valid credential even when get_auth_status()
|
|
# returns logged_in=False (e.g. token not in the hermes credential pool,
|
|
# or refresh token consumed by native Codex CLI / VS Code extension).
|
|
try:
|
|
from hermes_cli.auth import get_auth_status as _gas
|
|
status = _gas(pid)
|
|
if isinstance(status, dict) and status.get("logged_in"):
|
|
has_key = True
|
|
key_source = status.get("key_source", "oauth")
|
|
elif has_key:
|
|
# _provider_has_key() found a token in config.yaml — respect it
|
|
# rather than hiding a working credential from the Settings UI.
|
|
key_source = "config_yaml"
|
|
auth_error = status.get("error") if isinstance(status, dict) else None
|
|
else:
|
|
has_key = False
|
|
auth_error = status.get("error") if isinstance(status, dict) else None
|
|
except Exception:
|
|
# Import failed or auth check errored — don't override a known-good
|
|
# key just because the hermes_cli auth module is unavailable.
|
|
logger.debug("hermes_cli auth check failed for %s", pid, exc_info=True)
|
|
# keep has_key from _provider_has_key()
|
|
elif has_key:
|
|
env_var = _PROVIDER_ENV_VAR.get(pid)
|
|
if env_var:
|
|
env_path = _get_hermes_home() / ".env"
|
|
env_values = _load_env_file(env_path)
|
|
if env_values.get(env_var):
|
|
key_source = "env_file"
|
|
elif os.getenv(env_var):
|
|
key_source = "env_var"
|
|
else:
|
|
# Canonical name not set; check legacy aliases (e.g. lmstudio's
|
|
# pre-#1500 LMSTUDIO_API_KEY) so existing users see "env_file"
|
|
# instead of being misreported as "config_yaml" when the key
|
|
# actually lives in .env under the old name.
|
|
aliased = False
|
|
for alias in _PROVIDER_ENV_VAR_ALIASES.get(pid, ()) or ():
|
|
if env_values.get(alias):
|
|
key_source = "env_file"
|
|
aliased = True
|
|
break
|
|
if os.getenv(alias):
|
|
key_source = "env_var"
|
|
aliased = True
|
|
break
|
|
if not aliased:
|
|
key_source = "config_yaml"
|
|
else:
|
|
key_source = "config_yaml"
|
|
elif pid not in _PROVIDER_ENV_VAR:
|
|
# Fallback: provider is not a known API-key provider and not in
|
|
# the hardcoded _OAUTH_PROVIDERS set. It may be a custom or
|
|
# newly-added OAuth provider (e.g. Anthropic connected via OAuth).
|
|
# Check live auth status so the Providers tab agrees with the
|
|
# model picker (#1212).
|
|
#
|
|
# IMPORTANT: we skip providers in _PROVIDER_ENV_VAR because they
|
|
# are pure API-key providers — calling get_auth_status() for every
|
|
# unconfigured API-key provider would add unnecessary latency
|
|
# (network round-trip per provider) on the Settings page.
|
|
# Validate pid looks like a real provider before probing
|
|
import re as _re
|
|
if _re.match(r'^[a-z][a-z0-9_-]{0,63}$', pid):
|
|
try:
|
|
from hermes_cli.auth import get_auth_status as _gas
|
|
status = _gas(pid)
|
|
if isinstance(status, dict) and status.get("logged_in"):
|
|
has_key = True
|
|
# Constrain key_source to a known-safe closed set
|
|
_raw_ks = status.get("key_source", "")
|
|
key_source = _raw_ks if _raw_ks in {"oauth", "env", "config", "token"} else "oauth"
|
|
is_oauth = True
|
|
except Exception:
|
|
pass
|
|
|
|
models = list(_PROVIDER_MODELS.get(pid, []))
|
|
models_total = len(models)
|
|
# OpenAI Codex account catalogs drift independently from WebUI releases.
|
|
# The model picker already prefers hermes_cli + Codex local cache for
|
|
# this provider (the agent's `provider_model_ids("openai-codex")` filters
|
|
# IDs with `supported_in_api: false`, but Codex CLI still surfaces some
|
|
# of those — notably `gpt-5.3-codex-spark` from #1680 — in its picker).
|
|
# Merge both sources here so the providers card matches the picker
|
|
# exactly. Static entries remain the offline fallback when live
|
|
# discovery and the local Codex cache are both unavailable. (#1807
|
|
# follow-up to v0.51.19 #1812.)
|
|
if pid == "openai-codex":
|
|
live_ids = _read_live_provider_model_ids("openai-codex")
|
|
for mid in _read_visible_codex_cache_model_ids():
|
|
if mid not in live_ids:
|
|
live_ids.append(mid)
|
|
live_models = _models_from_live_provider_ids(pid, live_ids)
|
|
if live_models:
|
|
models = live_models
|
|
models_total = len(models)
|
|
# Nous Portal: prefer the live catalog so the providers card matches
|
|
# the dropdown picker (#1538). Same fallback shape as the static-only
|
|
# case below — when hermes_cli is unavailable or its lookup raises,
|
|
# we keep the four-entry curated list.
|
|
#
|
|
# On large-tier accounts (#1567 reporter Deor saw 396 entries), we
|
|
# render the same featured subset the picker uses so the providers
|
|
# card body doesn't become a 396-pill wall. The full count is still
|
|
# reported via models_total — surfaced in the header line as
|
|
# "396 models · OAuth" by static/panels.js — so the user knows the
|
|
# complete catalog is reachable (via /model autocomplete or a future
|
|
# "show all" disclosure if added).
|
|
if pid == "nous":
|
|
try:
|
|
from hermes_cli.models import provider_model_ids as _provider_model_ids
|
|
|
|
live_ids = _provider_model_ids("nous") or []
|
|
if live_ids:
|
|
# Lazy-import to avoid circular dep with api.config.
|
|
from api.config import _format_nous_label, _build_nous_featured_set
|
|
|
|
featured_ids, _extras = _build_nous_featured_set(live_ids)
|
|
models = [
|
|
{"id": f"@nous:{mid}", "label": _format_nous_label(mid)}
|
|
for mid in featured_ids
|
|
]
|
|
models_total = len(live_ids)
|
|
except Exception:
|
|
logger.debug("Failed to load Nous Portal models from hermes_cli")
|
|
# LM Studio: fetch live locally-loaded models so the providers card
|
|
# matches what's actually available on the user's server (#WebUI).
|
|
if pid == "lmstudio":
|
|
try:
|
|
from hermes_cli.models import provider_model_ids as _pmi
|
|
|
|
lm_live = _pmi("lmstudio") or []
|
|
if lm_live:
|
|
models = [{"id": mid, "label": mid} for mid in lm_live]
|
|
models_total = len(models)
|
|
except Exception:
|
|
logger.debug("Failed to load LM Studio models from hermes_cli")
|
|
# Also include models from config.yaml providers section
|
|
if isinstance(providers_cfg, dict):
|
|
provider_cfg = providers_cfg.get(pid, {})
|
|
if isinstance(provider_cfg, dict) and "models" in provider_cfg:
|
|
cfg_models = provider_cfg["models"]
|
|
if isinstance(cfg_models, dict):
|
|
models = models + [{"id": k, "label": k} for k in cfg_models.keys()]
|
|
elif isinstance(cfg_models, list):
|
|
models = models + [{"id": k, "label": k} for k in cfg_models]
|
|
# Recompute models_total when config.yaml contributes additional
|
|
# entries on top of the live/static catalog. For non-Nous
|
|
# providers models_total still equals len(models); for Nous
|
|
# we keep the live count (which already includes any models
|
|
# surfaced in the curated featured slice).
|
|
if pid != "nous":
|
|
models_total = len(models)
|
|
|
|
providers.append({
|
|
"id": pid,
|
|
"display_name": display_name,
|
|
"has_key": has_key,
|
|
"configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
|
|
"is_oauth": is_oauth,
|
|
"key_source": key_source,
|
|
"auth_error": auth_error,
|
|
"models": models,
|
|
# models_total reflects the complete catalog size (e.g. 396 for
|
|
# an enterprise Nous Portal account), even when "models" is
|
|
# trimmed to a featured subset for UI scannability. The frontend
|
|
# uses this for the header text "396 models · OAuth" so users
|
|
# know the full catalog exists and is reachable via the slash
|
|
# command. For providers that don't trim, models_total ==
|
|
# len(models) and the frontend behaves identically to before.
|
|
"models_total": models_total,
|
|
})
|
|
|
|
# Scan custom_providers from config.yaml (e.g. glmcode, timicc)
|
|
custom_providers_cfg = cfg.get("custom_providers", [])
|
|
if isinstance(custom_providers_cfg, list):
|
|
for cp in custom_providers_cfg:
|
|
if not isinstance(cp, dict) or not cp.get("name"):
|
|
continue
|
|
cp_name = str(cp["name"]).strip()
|
|
cp_id = _custom_provider_slug_from_name(cp_name)
|
|
if not cp_id:
|
|
logger.warning(
|
|
"Custom provider entry %r produced empty slug; skipping",
|
|
cp_name,
|
|
)
|
|
continue
|
|
# Collect models from `models` list or `model` single
|
|
cp_models = []
|
|
if isinstance(cp.get("models"), list):
|
|
cp_models = [{"id": str(m), "label": str(m)} for m in cp["models"]]
|
|
elif cp.get("model"):
|
|
cp_models = [{"id": cp["model"], "label": cp["model"]}]
|
|
# Check for env var reference (${VAR_NAME} pattern)
|
|
cp_api_key = str(cp.get("api_key") or "")
|
|
cp_has_key = bool(cp_api_key.strip())
|
|
# Replace env var reference to check actual value
|
|
if cp_api_key.startswith("${") and cp_api_key.endswith("}"):
|
|
env_var = cp_api_key[2:-1]
|
|
cp_has_key = bool(os.getenv(env_var, "").strip())
|
|
providers.append({
|
|
"id": cp_id,
|
|
"display_name": cp_name,
|
|
"has_key": cp_has_key,
|
|
"configurable": False, # custom providers managed via config.yaml
|
|
"key_source": "config_yaml" if cp_has_key else "none",
|
|
"models": cp_models,
|
|
})
|
|
|
|
# Determine active provider
|
|
active_provider = None
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, dict):
|
|
active_provider = model_cfg.get("provider")
|
|
|
|
return {
|
|
"providers": providers,
|
|
"active_provider": active_provider,
|
|
}
|
|
|
|
|
|
def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
|
|
"""Set or update the API key for a provider.
|
|
|
|
Writes the key to ``~/.hermes/.env`` using the standard env var name.
|
|
If ``api_key`` is None or empty, the key is removed.
|
|
|
|
Returns a status dict with the operation result.
|
|
"""
|
|
provider_id = provider_id.strip().lower()
|
|
|
|
if not provider_id:
|
|
return {"ok": False, "error": "Provider ID is required."}
|
|
|
|
if _provider_is_oauth(provider_id):
|
|
return {
|
|
"ok": False,
|
|
"error": f"'{_PROVIDER_DISPLAY.get(provider_id, provider_id)}' uses OAuth authentication. "
|
|
f"Use `hermes model` in the terminal to configure it.",
|
|
}
|
|
|
|
env_var = _PROVIDER_ENV_VAR.get(provider_id)
|
|
if not env_var:
|
|
return {
|
|
"ok": False,
|
|
"error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
|
|
f"This provider does not have a known env var mapping.",
|
|
}
|
|
|
|
# Validate API key format (basic sanity check)
|
|
if api_key:
|
|
api_key = api_key.strip()
|
|
if "\n" in api_key or "\r" in api_key:
|
|
return {"ok": False, "error": "API key must not contain newline characters."}
|
|
if len(api_key) < 8:
|
|
return {"ok": False, "error": "API key appears too short."}
|
|
|
|
env_path = _get_hermes_home() / ".env"
|
|
try:
|
|
_write_env_file(env_path, {env_var: api_key})
|
|
except ValueError as exc:
|
|
return {"ok": False, "error": str(exc)}
|
|
except Exception as exc:
|
|
logger.exception("Failed to write env file for provider %s", provider_id)
|
|
return {"ok": False, "error": f"Failed to save API key: {exc}"}
|
|
|
|
# Invalidate the model cache so the dropdown refreshes on next request.
|
|
# Using invalidate_models_cache() instead of reload_config() to avoid
|
|
# disrupting active streaming sessions that may be reading config.cfg.
|
|
invalidate_models_cache()
|
|
|
|
return {
|
|
"ok": True,
|
|
"provider": provider_id,
|
|
"display_name": _PROVIDER_DISPLAY.get(provider_id, provider_id),
|
|
"action": "updated" if api_key else "removed",
|
|
}
|
|
|
|
|
|
def remove_provider_key(provider_id: str) -> dict[str, Any]:
|
|
"""Remove the API key for a provider.
|
|
|
|
Removes the key from ``~/.hermes/.env`` (via ``set_provider_key``)
|
|
and also cleans up ``config.yaml`` if the key is stored there
|
|
(``providers.<id>.api_key`` or top-level ``model.api_key`` when this
|
|
provider is the active one).
|
|
|
|
Returns a status dict with the operation result.
|
|
"""
|
|
result = set_provider_key(provider_id, None)
|
|
|
|
# Even if the .env removal succeeded, the key might also live in
|
|
# config.yaml (e.g. providers.<id>.api_key or model.api_key).
|
|
# Clean those up so _provider_has_key() returns False after removal.
|
|
if result.get("ok"):
|
|
_clean_provider_key_from_config(provider_id)
|
|
|
|
return result
|
|
|
|
|
|
def _clean_provider_key_from_config(provider_id: str) -> None:
|
|
"""Remove provider API key entries from config.yaml.
|
|
|
|
Handles three storage locations:
|
|
1. ``providers.<id>.api_key`` — per-provider key
|
|
2. ``model.api_key`` — top-level key (only if provider is active)
|
|
3. ``custom_providers[].api_key`` — custom provider entries
|
|
|
|
Writes back to config.yaml only if something was actually removed.
|
|
Uses ``_cfg_lock`` to prevent TOCTOU races.
|
|
"""
|
|
from api.config import _cfg_lock
|
|
|
|
try:
|
|
# Resolve through api.config at call time instead of the function imported
|
|
# at module load. Several tests (and some profile flows) monkeypatch the
|
|
# config module's path resolver after api.providers has already been
|
|
# imported; using the stale imported reference can clean the wrong
|
|
# config.yaml.
|
|
import api.config as _config
|
|
config_path = _config._get_config_path()
|
|
except Exception:
|
|
return
|
|
|
|
if not config_path.exists():
|
|
return
|
|
|
|
try:
|
|
import yaml as _yaml
|
|
|
|
changed = False
|
|
|
|
with _cfg_lock:
|
|
raw = config_path.read_text(encoding="utf-8")
|
|
cfg = _yaml.safe_load(raw)
|
|
if not isinstance(cfg, dict):
|
|
return
|
|
|
|
# 1. Clean providers.<id>.api_key
|
|
providers_cfg = cfg.get("providers", {})
|
|
if isinstance(providers_cfg, dict):
|
|
provider_cfg = providers_cfg.get(provider_id, {})
|
|
if isinstance(provider_cfg, dict) and provider_cfg.get("api_key"):
|
|
del provider_cfg["api_key"]
|
|
changed = True
|
|
|
|
# 2. Clean model.api_key — only if this provider is the active one
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, dict) and model_cfg.get("api_key"):
|
|
active_provider = model_cfg.get("provider")
|
|
if active_provider and str(active_provider).strip().lower() == provider_id.lower():
|
|
del model_cfg["api_key"]
|
|
changed = True
|
|
|
|
# 3. Clean custom_providers[].api_key
|
|
custom_providers = cfg.get("custom_providers", [])
|
|
if isinstance(custom_providers, list):
|
|
for cp in custom_providers:
|
|
if isinstance(cp, dict):
|
|
if _custom_provider_name_matches(provider_id, cp.get("name")):
|
|
if cp.get("api_key"):
|
|
del cp["api_key"]
|
|
changed = True
|
|
|
|
if changed:
|
|
_save_yaml_config_file(config_path, cfg)
|
|
# Sync in-memory cache and bust model TTL cache
|
|
# MUST be called outside _cfg_lock to avoid deadlock:
|
|
# _cfg_lock is a threading.Lock (non-reentrant) and
|
|
# reload_config() also acquires _cfg_lock internally.
|
|
if changed:
|
|
reload_config()
|
|
except Exception:
|
|
logger.exception("Failed to clean provider key from config.yaml for %s", provider_id)
|