2026-04-23 01:09:22 +00:00
|
|
|
"""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
|
|
|
|
|
|
2026-06-07 19:07:13 +00:00
|
|
|
import atexit
|
2026-05-12 20:50:23 +00:00
|
|
|
import base64
|
|
|
|
|
import hashlib
|
2026-05-05 00:04:18 +00:00
|
|
|
import json
|
2026-04-23 01:09:22 +00:00
|
|
|
import logging
|
|
|
|
|
import os
|
2026-05-10 19:18:02 +00:00
|
|
|
import signal
|
2026-05-08 05:52:08 +00:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-05-10 19:18:02 +00:00
|
|
|
import threading
|
2026-05-12 20:50:23 +00:00
|
|
|
import time
|
2026-05-05 00:04:18 +00:00
|
|
|
import urllib.error
|
|
|
|
|
import urllib.request
|
2026-05-17 02:49:15 +00:00
|
|
|
from contextlib import contextmanager, nullcontext
|
2026-05-13 02:05:20 +00:00
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-04-23 01:09:22 +00:00
|
|
|
from pathlib import Path
|
2026-05-08 05:52:08 +00:00
|
|
|
from types import SimpleNamespace
|
2026-04-23 01:09:22 +00:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-05-17 02:49:15 +00:00
|
|
|
try: # POSIX-only; Windows-style environments fall back to process-local locking.
|
|
|
|
|
import fcntl
|
|
|
|
|
except ImportError: # pragma: no cover - exercised only where fcntl is unavailable
|
|
|
|
|
fcntl = None # type: ignore[assignment]
|
|
|
|
|
|
2026-04-23 01:09:22 +00:00
|
|
|
from api.config import (
|
|
|
|
|
_PROVIDER_DISPLAY,
|
|
|
|
|
_PROVIDER_MODELS,
|
2026-06-29 05:59:51 +00:00
|
|
|
_coerce_provider_cost_budget,
|
2026-05-11 05:48:46 +00:00
|
|
|
_custom_provider_slug_from_name,
|
2026-05-07 12:41:14 +00:00
|
|
|
_get_label_for_model,
|
2026-05-07 17:58:52 +00:00
|
|
|
_models_from_live_provider_ids,
|
2026-06-20 23:52:52 +00:00
|
|
|
_pool_entry_payloads,
|
2026-05-07 17:58:52 +00:00
|
|
|
_read_live_provider_model_ids,
|
|
|
|
|
_read_visible_codex_cache_model_ids,
|
2026-04-26 21:24:20 +00:00
|
|
|
_save_yaml_config_file,
|
2026-06-20 23:52:52 +00:00
|
|
|
_thread_local_env_value,
|
2026-04-23 01:09:22 +00:00
|
|
|
get_config,
|
|
|
|
|
invalidate_models_cache,
|
2026-04-26 21:24:20 +00:00
|
|
|
reload_config,
|
2026-04-23 01:09:22 +00:00
|
|
|
)
|
2026-06-05 18:35:26 +00:00
|
|
|
from api.plugin_providers import (
|
|
|
|
|
effective_provider_display_name,
|
|
|
|
|
effective_provider_env_var,
|
|
|
|
|
is_plugin_model_provider,
|
|
|
|
|
plugin_model_provider_ids,
|
|
|
|
|
)
|
2026-04-23 01:09:22 +00:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-05-11 05:48:46 +00:00
|
|
|
|
2026-06-05 18:35:26 +00:00
|
|
|
def _provider_env_var_for(provider_id: str) -> str | None:
|
|
|
|
|
"""Resolve the API-key env var for a provider (static table + plugin profiles)."""
|
|
|
|
|
return effective_provider_env_var(provider_id, _PROVIDER_ENV_VAR)
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 05:48:46 +00:00
|
|
|
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
|
|
|
|
|
|
2026-05-05 00:04:18 +00:00
|
|
|
_OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key"
|
|
|
|
|
_PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0
|
2026-05-08 05:52:08 +00:00
|
|
|
_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS = 35.0
|
2026-05-12 20:50:23 +00:00
|
|
|
_ACCOUNT_USAGE_CACHE_TTL_SECONDS = 45.0
|
|
|
|
|
_ACCOUNT_USAGE_CACHE_MAX_ENTRIES = 64
|
2026-06-07 19:07:13 +00:00
|
|
|
_ACCOUNT_USAGE_WORKER_IDLE_SECONDS = 5 * 60
|
2026-05-07 09:36:04 +00:00
|
|
|
_ACCOUNT_USAGE_PROVIDERS = frozenset({"openai-codex", "anthropic"})
|
2026-05-10 19:18:02 +00:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
2026-05-12 20:50:23 +00:00
|
|
|
# Short-lived account-usage cache. The Codex pooled probe may check multiple
|
2026-05-14 01:01:47 +00:00
|
|
|
# credentials, so cache sanitized snapshots briefly to avoid re-querying the
|
2026-05-12 20:50:23 +00:00
|
|
|
# provider on every Settings repaint/profile-panel refresh. Pool composition
|
|
|
|
|
# changes can be stale for at most this TTL; that is preferred to hammering the
|
2026-05-14 01:01:47 +00:00
|
|
|
# provider usage API while the Settings panel is open. Transient None probe
|
|
|
|
|
# results are intentionally not cached; known exhausted/unavailable states are
|
|
|
|
|
# represented as non-None snapshots and remain cacheable.
|
2026-05-12 20:50:23 +00:00
|
|
|
_account_usage_status_cache: dict[tuple[str, str, str], tuple[float, Any]] = {}
|
|
|
|
|
_account_usage_status_cache_lock = threading.Lock()
|
2026-06-10 16:25:40 +00:00
|
|
|
_account_usage_worker_pool: dict[str, list["_AccountUsageProbeWorker"]] = {}
|
2026-06-07 19:07:13 +00:00
|
|
|
_account_usage_worker_pool_lock = threading.Lock()
|
2026-05-12 20:50:23 +00:00
|
|
|
|
2026-06-10 16:25:40 +00:00
|
|
|
# Per-home worker pool configuration for probe tail-latency reduction (#3787)
|
|
|
|
|
_ACCOUNT_USAGE_WORKERS_PER_HOME = 2
|
|
|
|
|
|
2026-05-10 19:18:02 +00:00
|
|
|
|
|
|
|
|
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
|
2026-06-07 19:07:13 +00:00
|
|
|
# subprocess startup works on Windows/macOS without changes.
|
2026-05-10 19:18:02 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 05:52:08 +00:00
|
|
|
_ACCOUNT_USAGE_SUBPROCESS_CODE = r"""
|
2026-05-12 03:46:24 +00:00
|
|
|
import base64
|
2026-05-08 05:52:08 +00:00
|
|
|
import json
|
2026-06-07 19:07:13 +00:00
|
|
|
import os
|
2026-05-08 05:52:08 +00:00
|
|
|
import sys
|
2026-05-13 03:00:24 +00:00
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-05-13 02:05:20 +00:00
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-05-12 03:46:24 +00:00
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from urllib import request as urllib_request
|
2026-05-08 05:52:08 +00:00
|
|
|
|
|
|
|
|
from agent.account_usage import fetch_account_usage
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 03:46:24 +00:00
|
|
|
_CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
2026-05-12 20:50:23 +00:00
|
|
|
_CODEX_POOL_USAGE_TIMEOUT_SECONDS = 4.0
|
2026-05-13 03:00:24 +00:00
|
|
|
_CODEX_POOL_MAX_WORKERS = 6
|
2026-05-12 03:46:24 +00:00
|
|
|
|
|
|
|
|
|
2026-05-08 05:52:08 +00:00
|
|
|
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),
|
|
|
|
|
})
|
2026-05-12 20:50:23 +00:00
|
|
|
payload = {
|
2026-05-08 05:52:08 +00:00
|
|
|
"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)),
|
|
|
|
|
}
|
2026-05-12 20:50:23 +00:00
|
|
|
pool = getattr(snapshot, "pool", None)
|
|
|
|
|
if isinstance(pool, dict):
|
|
|
|
|
payload["pool"] = pool
|
|
|
|
|
return payload
|
2026-05-08 05:52:08 +00:00
|
|
|
|
|
|
|
|
|
2026-05-12 03:46:24 +00:00
|
|
|
def _snapshot_available(snapshot):
|
|
|
|
|
if snapshot is None:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
return bool(getattr(snapshot, "available", False))
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _number(value):
|
|
|
|
|
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 Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_dt(value):
|
|
|
|
|
if value in (None, ""):
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(value, (int, float)):
|
|
|
|
|
try:
|
|
|
|
|
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return None
|
|
|
|
|
if text.endswith("Z"):
|
|
|
|
|
text = text[:-1] + "+00:00"
|
|
|
|
|
try:
|
|
|
|
|
dt = datetime.fromisoformat(text)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None
|
|
|
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _title_case_slug(value):
|
|
|
|
|
cleaned = str(value or "").strip()
|
|
|
|
|
if not cleaned:
|
|
|
|
|
return None
|
|
|
|
|
return cleaned.replace("_", " ").replace("-", " ").title()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_codex_usage_url(base_url):
|
|
|
|
|
normalized = str(base_url or "").strip().rstrip("/") or _CODEX_DEFAULT_BASE_URL
|
|
|
|
|
if normalized.endswith("/codex"):
|
|
|
|
|
normalized = normalized[: -len("/codex")]
|
|
|
|
|
if "/backend-api" in normalized:
|
|
|
|
|
return normalized + "/wham/usage"
|
|
|
|
|
return normalized + "/api/codex/usage"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _jwt_claims(token):
|
|
|
|
|
if not isinstance(token, str) or token.count(".") != 2:
|
|
|
|
|
return {}
|
|
|
|
|
payload = token.split(".")[1]
|
|
|
|
|
payload += "=" * ((4 - len(payload) % 4) % 4)
|
|
|
|
|
try:
|
|
|
|
|
claims = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8"))
|
|
|
|
|
except Exception:
|
|
|
|
|
return {}
|
|
|
|
|
return claims if isinstance(claims, dict) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _codex_usage_headers(access_token):
|
|
|
|
|
headers = {
|
|
|
|
|
"Authorization": "Bearer " + access_token,
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
"User-Agent": "codex_cli_rs/0.0.0 (Hermes WebUI)",
|
|
|
|
|
"originator": "codex_cli_rs",
|
|
|
|
|
}
|
|
|
|
|
auth_claim = _jwt_claims(access_token).get("https://api.openai.com/auth")
|
|
|
|
|
account_id = None
|
|
|
|
|
if isinstance(auth_claim, dict):
|
|
|
|
|
account_id = auth_claim.get("chatgpt_account_id")
|
|
|
|
|
if isinstance(account_id, str) and account_id.strip():
|
|
|
|
|
headers["ChatGPT-Account-ID"] = account_id.strip()
|
|
|
|
|
return headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_value(entry, *names):
|
|
|
|
|
for name in names:
|
|
|
|
|
try:
|
|
|
|
|
value = getattr(entry, name)
|
|
|
|
|
except Exception:
|
|
|
|
|
value = None
|
|
|
|
|
if value in (None, ""):
|
|
|
|
|
continue
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
if text:
|
|
|
|
|
return text
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _codex_snapshot_from_usage_payload(payload):
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
payload = {}
|
|
|
|
|
rate_limit = payload.get("rate_limit")
|
|
|
|
|
if not isinstance(rate_limit, dict):
|
|
|
|
|
rate_limit = {}
|
|
|
|
|
windows = []
|
|
|
|
|
for key, label in (("primary_window", "Session"), ("secondary_window", "Weekly")):
|
|
|
|
|
window = rate_limit.get(key)
|
|
|
|
|
if not isinstance(window, dict):
|
|
|
|
|
continue
|
|
|
|
|
used = _number(window.get("used_percent"))
|
|
|
|
|
if used is None:
|
|
|
|
|
continue
|
|
|
|
|
windows.append(SimpleNamespace(
|
|
|
|
|
label=label,
|
|
|
|
|
used_percent=float(used),
|
|
|
|
|
reset_at=_parse_dt(window.get("reset_at")),
|
|
|
|
|
detail=None,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
details = []
|
|
|
|
|
credits = payload.get("credits")
|
|
|
|
|
if isinstance(credits, dict) and credits.get("has_credits"):
|
|
|
|
|
balance = _number(credits.get("balance"))
|
|
|
|
|
if balance is not None:
|
|
|
|
|
details.append("Credits balance: $" + format(float(balance), ".2f"))
|
|
|
|
|
elif credits.get("unlimited"):
|
|
|
|
|
details.append("Credits balance: unlimited")
|
|
|
|
|
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
provider="openai-codex",
|
|
|
|
|
source="usage_api",
|
|
|
|
|
title="Account limits",
|
|
|
|
|
plan=_title_case_slug(payload.get("plan_type")),
|
|
|
|
|
windows=tuple(windows),
|
|
|
|
|
details=tuple(details),
|
|
|
|
|
available=bool(windows or details),
|
|
|
|
|
unavailable_reason=None,
|
|
|
|
|
fetched_at=datetime.now(timezone.utc),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 20:50:23 +00:00
|
|
|
def _snapshot_windows_payload(snapshot):
|
|
|
|
|
windows = []
|
|
|
|
|
for window in getattr(snapshot, "windows", ()) or ():
|
|
|
|
|
label = str(getattr(window, "label", "") or "").strip()
|
|
|
|
|
if not label:
|
|
|
|
|
continue
|
|
|
|
|
used_percent = _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": _iso(getattr(window, "reset_at", None)),
|
|
|
|
|
"detail": getattr(window, "detail", None),
|
|
|
|
|
})
|
|
|
|
|
return windows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _snapshot_details_payload(snapshot):
|
|
|
|
|
return [
|
|
|
|
|
str(detail).strip()
|
|
|
|
|
for detail in (getattr(snapshot, "details", ()) or ())
|
|
|
|
|
if str(detail).strip()
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_entry_label(entry, index):
|
|
|
|
|
label = _entry_value(entry, "label", "source") or ""
|
|
|
|
|
if not label:
|
|
|
|
|
label = "Credential " + str(index)
|
|
|
|
|
label = " ".join(str(label).split())
|
|
|
|
|
if len(label) > 64:
|
|
|
|
|
label = label[:61].rstrip() + "..."
|
|
|
|
|
return label
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_unavailable_reason(reason):
|
|
|
|
|
text = " ".join(str(reason or "").split())
|
|
|
|
|
if not text:
|
|
|
|
|
return None
|
|
|
|
|
lowered = text.lower()
|
|
|
|
|
sensitive_terms = ("access_token", "refresh_token", "authorization", "bearer ", "jwt", "secret")
|
|
|
|
|
if any(term in lowered for term in sensitive_terms):
|
|
|
|
|
return "Usage unavailable for this credential."
|
|
|
|
|
return text[:180]
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 02:05:20 +00:00
|
|
|
def _entry_exhausted_ttl_seconds(error_code):
|
|
|
|
|
code = str(error_code or "").strip()
|
|
|
|
|
if code == "401":
|
|
|
|
|
return 5 * 60
|
|
|
|
|
return 60 * 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_pool_exhausted_until(entry):
|
|
|
|
|
if str(_entry_value(entry, "last_status") or "").strip().lower() != "exhausted":
|
|
|
|
|
return None
|
|
|
|
|
reset_at = _parse_dt(getattr(entry, "last_error_reset_at", None))
|
|
|
|
|
if reset_at is not None:
|
|
|
|
|
return reset_at
|
|
|
|
|
status_at = _parse_dt(getattr(entry, "last_status_at", None))
|
|
|
|
|
if status_at is None:
|
|
|
|
|
return None
|
|
|
|
|
return status_at + timedelta(seconds=_entry_exhausted_ttl_seconds(_entry_value(entry, "last_error_code")))
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 20:50:23 +00:00
|
|
|
def _entry_is_pool_exhausted(entry):
|
2026-05-13 02:05:20 +00:00
|
|
|
exhausted_until = _entry_pool_exhausted_until(entry)
|
|
|
|
|
return exhausted_until is not None and datetime.now(timezone.utc) < exhausted_until
|
2026-05-12 20:50:23 +00:00
|
|
|
|
|
|
|
|
|
2026-05-12 21:27:22 +00:00
|
|
|
def _entry_pool_exhausted_reason(entry):
|
|
|
|
|
code = _entry_value(entry, "last_error_code")
|
2026-05-13 04:27:51 +00:00
|
|
|
reset_at = _entry_pool_retry_after(entry)
|
2026-05-12 21:27:22 +00:00
|
|
|
reason = "Credential pool marked this credential exhausted"
|
|
|
|
|
if code:
|
|
|
|
|
reason += " after provider status " + code
|
|
|
|
|
if reset_at:
|
|
|
|
|
reason += "; retry after " + reset_at
|
|
|
|
|
return reason + "."
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 04:27:51 +00:00
|
|
|
def _entry_pool_retry_after(entry):
|
|
|
|
|
return _iso(_entry_pool_exhausted_until(entry))
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 20:50:23 +00:00
|
|
|
def _fetch_codex_entry_snapshot(entry):
|
|
|
|
|
access_token = _entry_value(entry, "runtime_api_key", "access_token")
|
|
|
|
|
if not access_token:
|
|
|
|
|
return None, False, "No runtime token available."
|
|
|
|
|
base_url = _entry_value(entry, "runtime_base_url", "base_url") or _CODEX_DEFAULT_BASE_URL
|
|
|
|
|
request = urllib_request.Request(
|
|
|
|
|
_resolve_codex_usage_url(base_url),
|
|
|
|
|
headers=_codex_usage_headers(access_token),
|
|
|
|
|
)
|
|
|
|
|
with urllib_request.urlopen(request, timeout=_CODEX_POOL_USAGE_TIMEOUT_SECONDS) as response:
|
|
|
|
|
payload = json.loads(response.read().decode("utf-8") or "{}")
|
|
|
|
|
return _codex_snapshot_from_usage_payload(payload), True, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _best_remaining_by_window(rows):
|
|
|
|
|
best = {}
|
|
|
|
|
for row in rows:
|
|
|
|
|
if row.get("status") != "available":
|
|
|
|
|
continue
|
|
|
|
|
label = row.get("label") or "Credential"
|
|
|
|
|
for window in row.get("windows") or []:
|
|
|
|
|
if not isinstance(window, dict):
|
|
|
|
|
continue
|
|
|
|
|
window_label = str(window.get("label") or "").strip()
|
|
|
|
|
remaining = _number(window.get("remaining_percent"))
|
|
|
|
|
if not window_label or remaining is None:
|
|
|
|
|
continue
|
|
|
|
|
candidate = {
|
|
|
|
|
"label": window_label,
|
|
|
|
|
"remaining_percent": remaining,
|
|
|
|
|
"used_percent": window.get("used_percent"),
|
|
|
|
|
"reset_at": window.get("reset_at"),
|
|
|
|
|
"detail": window.get("detail"),
|
|
|
|
|
"credential_label": label,
|
|
|
|
|
}
|
|
|
|
|
current = best.get(window_label.lower())
|
2026-05-14 01:01:47 +00:00
|
|
|
# The normalized Codex account-limit payload currently exposes
|
|
|
|
|
# percentages, not absolute request/token capacity. If absolute
|
|
|
|
|
# remaining capacity becomes available, prefer it here.
|
2026-05-12 20:50:23 +00:00
|
|
|
if current is None or float(remaining) > float(current.get("remaining_percent") or -1):
|
|
|
|
|
best[window_label.lower()] = candidate
|
|
|
|
|
return list(best.values())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _next_reset_at(rows):
|
|
|
|
|
best_dt = None
|
|
|
|
|
best_text = None
|
|
|
|
|
for row in rows:
|
|
|
|
|
for window in row.get("windows") or []:
|
|
|
|
|
if not isinstance(window, dict):
|
|
|
|
|
continue
|
|
|
|
|
reset_text = window.get("reset_at")
|
|
|
|
|
dt = _parse_dt(reset_text)
|
|
|
|
|
if dt is None:
|
|
|
|
|
continue
|
|
|
|
|
if best_dt is None or dt < best_dt:
|
|
|
|
|
best_dt = dt
|
|
|
|
|
best_text = _iso(dt)
|
|
|
|
|
return best_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _codex_pool_snapshot(entries, rows, queried):
|
|
|
|
|
available_rows = [row for row in rows if row.get("status") == "available"]
|
|
|
|
|
exhausted_rows = [row for row in rows if row.get("status") == "exhausted"]
|
|
|
|
|
failed_rows = [row for row in rows if row.get("status") not in {"available", "exhausted"}]
|
|
|
|
|
plans = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
plan = row.get("plan")
|
|
|
|
|
if plan and plan not in plans:
|
|
|
|
|
plans.append(plan)
|
|
|
|
|
best_windows = _best_remaining_by_window(rows)
|
|
|
|
|
pool = {
|
|
|
|
|
"total_credentials": len(entries),
|
|
|
|
|
"queried_credentials": queried,
|
|
|
|
|
"available_credentials": len(available_rows),
|
|
|
|
|
"exhausted_credentials": len(exhausted_rows),
|
|
|
|
|
"failed_credentials": len(failed_rows),
|
|
|
|
|
"plans": plans,
|
|
|
|
|
"next_reset_at": _next_reset_at(rows),
|
|
|
|
|
"best_remaining_by_window": best_windows,
|
|
|
|
|
"credentials": rows,
|
|
|
|
|
}
|
|
|
|
|
details = [str(len(available_rows)) + "/" + str(len(entries)) + " credentials available"]
|
|
|
|
|
if exhausted_rows:
|
|
|
|
|
details.append(str(len(exhausted_rows)) + " exhausted")
|
|
|
|
|
if failed_rows:
|
|
|
|
|
details.append(str(len(failed_rows)) + " failed to load")
|
|
|
|
|
if plans:
|
|
|
|
|
details.append("Plans: " + ", ".join(plans))
|
|
|
|
|
plan = plans[0] if len(plans) == 1 else None
|
|
|
|
|
windows = tuple(
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
label=window.get("label"),
|
|
|
|
|
used_percent=window.get("used_percent"),
|
|
|
|
|
reset_at=window.get("reset_at"),
|
|
|
|
|
detail="Best of " + str(len(available_rows)) + " available credentials",
|
|
|
|
|
)
|
|
|
|
|
for window in best_windows
|
|
|
|
|
)
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
provider="openai-codex",
|
|
|
|
|
source="usage_api_pool",
|
|
|
|
|
title="Account limits",
|
|
|
|
|
plan=plan,
|
|
|
|
|
windows=windows,
|
|
|
|
|
details=tuple(details),
|
|
|
|
|
available=bool(available_rows),
|
|
|
|
|
unavailable_reason=None if available_rows else "No Codex pool credentials returned available account limits.",
|
|
|
|
|
fetched_at=datetime.now(timezone.utc),
|
|
|
|
|
pool=pool,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 03:00:24 +00:00
|
|
|
def _codex_pool_exhausted_row(entry, index):
|
|
|
|
|
label = _safe_entry_label(entry, index)
|
2026-05-13 04:27:51 +00:00
|
|
|
retry_after = _entry_pool_retry_after(entry)
|
2026-05-13 03:00:24 +00:00
|
|
|
return {
|
|
|
|
|
"label": label,
|
|
|
|
|
"status": "exhausted",
|
|
|
|
|
"plan": None,
|
|
|
|
|
"windows": [],
|
|
|
|
|
"details": [],
|
|
|
|
|
"unavailable_reason": _entry_pool_exhausted_reason(entry),
|
2026-05-13 04:27:51 +00:00
|
|
|
"retry_after": retry_after,
|
2026-05-13 03:00:24 +00:00
|
|
|
"fetched_at": None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _probe_codex_pool_entry(item):
|
|
|
|
|
index, entry = item
|
|
|
|
|
label = _safe_entry_label(entry, index)
|
|
|
|
|
did_query_count = 0
|
|
|
|
|
try:
|
|
|
|
|
snapshot, did_query, reason = _fetch_codex_entry_snapshot(entry)
|
|
|
|
|
if did_query:
|
|
|
|
|
did_query_count = 1
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
snapshot = None
|
|
|
|
|
reason = str(exc)
|
|
|
|
|
windows = _snapshot_windows_payload(snapshot) if snapshot is not None else []
|
|
|
|
|
details = _snapshot_details_payload(snapshot) if snapshot is not None else []
|
|
|
|
|
snapshot_available = _snapshot_available(snapshot)
|
|
|
|
|
status = "available" if snapshot_available else "unavailable"
|
|
|
|
|
row = {
|
|
|
|
|
"label": label,
|
|
|
|
|
"status": status,
|
|
|
|
|
"plan": getattr(snapshot, "plan", None) if snapshot is not None else None,
|
|
|
|
|
"windows": windows,
|
|
|
|
|
"details": details,
|
|
|
|
|
"unavailable_reason": None if snapshot_available else _safe_unavailable_reason(reason or getattr(snapshot, "unavailable_reason", None)),
|
|
|
|
|
"fetched_at": _iso(getattr(snapshot, "fetched_at", None)) if snapshot is not None else None,
|
|
|
|
|
}
|
|
|
|
|
return index, row, did_query_count
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 03:46:24 +00:00
|
|
|
def _fetch_codex_account_usage_from_pool():
|
|
|
|
|
try:
|
|
|
|
|
from agent.credential_pool import load_pool
|
|
|
|
|
|
|
|
|
|
pool = load_pool("openai-codex")
|
2026-05-13 03:00:24 +00:00
|
|
|
entries = list(pool.entries()) if pool is not None and hasattr(pool, "entries") else []
|
2026-05-12 20:50:23 +00:00
|
|
|
if not entries:
|
2026-05-12 03:46:24 +00:00
|
|
|
return None
|
2026-05-13 03:00:24 +00:00
|
|
|
rows_by_index = {}
|
|
|
|
|
probe_items = []
|
2026-05-12 20:50:23 +00:00
|
|
|
queried = 0
|
|
|
|
|
for index, entry in enumerate(entries, start=1):
|
2026-05-13 03:00:24 +00:00
|
|
|
if _entry_is_pool_exhausted(entry):
|
|
|
|
|
rows_by_index[index] = _codex_pool_exhausted_row(entry, index)
|
|
|
|
|
else:
|
|
|
|
|
probe_items.append((index, entry))
|
|
|
|
|
if probe_items:
|
|
|
|
|
max_workers = min(_CODEX_POOL_MAX_WORKERS, len(probe_items))
|
|
|
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
|
|
|
for index, row, did_query_count in executor.map(_probe_codex_pool_entry, probe_items):
|
|
|
|
|
rows_by_index[index] = row
|
|
|
|
|
queried += did_query_count
|
|
|
|
|
rows = [rows_by_index[index] for index in range(1, len(entries) + 1)]
|
2026-05-12 20:50:23 +00:00
|
|
|
return _codex_pool_snapshot(entries, rows, queried)
|
2026-05-12 03:46:24 +00:00
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-06-07 19:07:13 +00:00
|
|
|
def _fetch_snapshot(provider, api_key, env_var=None):
|
|
|
|
|
previous = os.environ.get(env_var) if env_var else None
|
|
|
|
|
had_previous = bool(env_var and env_var in os.environ)
|
|
|
|
|
if env_var and api_key:
|
|
|
|
|
os.environ[env_var] = api_key
|
|
|
|
|
try:
|
|
|
|
|
try:
|
|
|
|
|
snapshot = fetch_account_usage(provider, api_key=api_key)
|
|
|
|
|
except Exception:
|
|
|
|
|
snapshot = None
|
|
|
|
|
if str(provider or "").strip().lower() == "openai-codex":
|
|
|
|
|
pool_snapshot = _fetch_codex_account_usage_from_pool()
|
|
|
|
|
if isinstance(getattr(pool_snapshot, "pool", None), dict):
|
|
|
|
|
snapshot = pool_snapshot
|
|
|
|
|
return _snapshot_payload(snapshot)
|
|
|
|
|
finally:
|
|
|
|
|
if env_var and api_key:
|
|
|
|
|
if had_previous:
|
|
|
|
|
os.environ[env_var] = previous
|
|
|
|
|
else:
|
|
|
|
|
os.environ.pop(env_var, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_worker():
|
|
|
|
|
for raw_line in sys.stdin:
|
|
|
|
|
try:
|
|
|
|
|
request = json.loads(raw_line)
|
|
|
|
|
provider = request.get("provider")
|
|
|
|
|
api_key = request.get("api_key") or None
|
|
|
|
|
env_var = request.get("env_var") or None
|
|
|
|
|
payload = _fetch_snapshot(provider, api_key, env_var=env_var)
|
|
|
|
|
except Exception:
|
|
|
|
|
payload = None
|
|
|
|
|
print(json.dumps(payload), flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if len(sys.argv) > 1 and sys.argv[1] == "--worker":
|
|
|
|
|
_run_worker()
|
|
|
|
|
else:
|
|
|
|
|
provider = sys.argv[1]
|
|
|
|
|
api_key = sys.argv[2] or None
|
|
|
|
|
print(json.dumps(_fetch_snapshot(provider, api_key)), flush=True)
|
2026-05-08 05:52:08 +00:00
|
|
|
"""
|
2026-05-05 00:04:18 +00:00
|
|
|
|
2026-06-19 05:08:48 +00:00
|
|
|
|
2026-04-23 01:09:22 +00:00
|
|
|
# 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",
|
2026-04-29 07:50:32 +00:00
|
|
|
"minimax-cn": "MINIMAX_CN_API_KEY",
|
2026-04-23 01:09:22 +00:00
|
|
|
"mistralai": "MISTRAL_API_KEY",
|
|
|
|
|
"x-ai": "XAI_API_KEY",
|
2026-05-10 23:33:52 +00:00
|
|
|
"xiaomi": "XIAOMI_API_KEY",
|
2026-06-24 03:31:33 +00:00
|
|
|
"neuralwatt": "NEURALWATT_API_KEY",
|
2026-04-23 01:09:22 +00:00
|
|
|
"opencode-zen": "OPENCODE_ZEN_API_KEY",
|
|
|
|
|
"opencode-go": "OPENCODE_GO_API_KEY",
|
fix: TTS toggle CSS specificity collision (#1409) + Ollama env var bleed (#1410)
Two unrelated UX/Settings bugs, both small surgical fixes with regression
tests.
Issue #1409 — TTS toggle has no effect
=======================================
Reported via Discord: ticking Settings → Voice → "Text-to-Speech for
responses" did nothing. The speaker icon never appeared on assistant
messages despite the checkbox saving to localStorage correctly.
Root cause (CSS specificity collision):
static/panels.js _applyTtsEnabled() set
btn.style.display = enabled ? '' : 'none'
on every .msg-tts-btn. The '' branch removes the inline override, after
which the .msg-tts-btn { display:none; } rule from style.css re-hides the
button. Both branches left the icon hidden, so the toggle has been
silently broken since #499 first shipped the TTS feature.
Fix (body-class toggle, Option B from the issue):
- panels.js: _applyTtsEnabled now toggles body.classList('tts-enabled')
- style.css: new compound selector
body.tts-enabled .msg-tts-btn { display:inline-flex; align-items:center; }
- default-hidden rule (.msg-tts-btn{display:none;}) preserved so the icon
stays hidden by default (CSS-only state)
- boot.js paths that already call _applyTtsEnabled(localStorage…) work
unchanged — the new function applies state at the body level instead of
inline-styling individual buttons, so the rule survives renderMd()
re-renders without re-querying every button
Verified end-to-end against live server: getComputedStyle on a probe
.msg-tts-btn returns display:flex when body has tts-enabled, display:none
when it doesn't. Two regression tests in TestIssue1409TtsToggleBodyClass
explicitly check for the body-class shape and forbid the broken inline-style
pattern.
Issue #1410 — Ollama (local) shows "API key configured" when only
Ollama Cloud key is set
=================================================================
Reported via Discord: configuring Ollama Cloud lit up the local Ollama card
too. Both providers were mapped to OLLAMA_API_KEY in api/providers.py
_PROVIDER_ENV_VAR.
Root cause:
api/providers.py:47-48
"ollama": "OLLAMA_API_KEY",
"ollama-cloud": "OLLAMA_API_KEY",
_provider_has_key("ollama") found the value the user set for Ollama Cloud
and returned True. But the runtime code path in
hermes_cli/runtime_provider.py only consumes OLLAMA_API_KEY when the base
URL hostname is ollama.com (Ollama Cloud) — local Ollama is keyless by
default and reaches a custom base URL with no auth. The WebUI was
reporting "configured" for a key local Ollama doesn't even read.
Fix (Option A from the issue body, preferred):
- Drop bare "ollama" from _PROVIDER_ENV_VAR with an inline comment
explaining why
- _provider_has_key("ollama") falls through to the config.yaml branch,
which already supports providers.ollama.api_key for local users who
genuinely need to set a token
- ollama-cloud retains its OLLAMA_API_KEY mapping unchanged
Verified end-to-end against live server with OLLAMA_API_KEY=sk-cloud-key-test
in env: GET /api/providers reports has_key=True only for ollama-cloud, and
has_key=False for bare ollama. Two regression tests in
TestIssue1410OllamaEnvVarBleed cover the bleed-prevention case AND the
"local user with config.yaml api_key still reports configured" case to
guard against over-correction.
Tests
-----
3572 passed, 2 skipped, 3 xpassed (was 3567 — added 5 new regression tests).
Closes #1409
Closes #1410
Reported by @AvidFuturist (Discord, May 1 2026)
2026-05-01 17:14:51 +00:00
|
|
|
# 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().
|
2026-04-23 01:09:22 +00:00
|
|
|
"ollama-cloud": "OLLAMA_API_KEY",
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
# 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",
|
2026-04-28 10:17:19 +00:00
|
|
|
"nvidia": "NVIDIA_API_KEY",
|
2026-04-23 01:09:22 +00:00
|
|
|
}
|
|
|
|
|
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
# 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",),
|
2026-05-30 01:53:17 +00:00
|
|
|
# #3145 — provider detection treats OPENCODE_API_KEY as enabling both
|
|
|
|
|
# OpenCode Zen and OpenCode Go. The runtime-facing lookup must read the same
|
|
|
|
|
# shared bridge key after the provider-specific slot, otherwise Settings can
|
|
|
|
|
# show the groups as configured while chat fails the no-key path.
|
|
|
|
|
"opencode-zen": ("OPENCODE_API_KEY",),
|
|
|
|
|
"opencode-go": ("OPENCODE_API_KEY",),
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 08:17:44 +00:00
|
|
|
_SELF_HOSTED_PROVIDER_IDS = frozenset({"ollama", "lmstudio"})
|
|
|
|
|
|
2026-06-20 07:37:35 +00:00
|
|
|
|
|
|
|
|
def _provider_credential_env_vars() -> tuple[str, ...]:
|
|
|
|
|
names = {name for name in _PROVIDER_ENV_VAR.values() if name}
|
|
|
|
|
for aliases in _PROVIDER_ENV_VAR_ALIASES.values():
|
|
|
|
|
for alias in aliases or ():
|
|
|
|
|
if alias:
|
|
|
|
|
names.add(alias)
|
|
|
|
|
return tuple(sorted(names))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_PROVIDER_CREDENTIAL_ENV_VARS = _provider_credential_env_vars()
|
|
|
|
|
|
2026-04-23 01:09:22 +00:00
|
|
|
# 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",
|
2026-04-28 10:30:02 +00:00
|
|
|
"copilot-acp",
|
2026-04-23 01:09:22 +00:00
|
|
|
"nous",
|
2026-04-28 10:30:02 +00:00
|
|
|
"openai-codex",
|
|
|
|
|
"qwen-oauth",
|
2026-05-19 02:49:54 +00:00
|
|
|
"xai-oauth",
|
2026-04-23 01:09:22 +00:00
|
|
|
})
|
|
|
|
|
|
2026-06-19 05:08:48 +00:00
|
|
|
|
|
|
|
|
def _entry_value(entry, *names):
|
|
|
|
|
for name in names:
|
|
|
|
|
try:
|
|
|
|
|
value = getattr(entry, name)
|
|
|
|
|
except Exception:
|
|
|
|
|
value = None
|
|
|
|
|
if value in (None, ""):
|
|
|
|
|
continue
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
if text:
|
|
|
|
|
return text
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_dt(value):
|
|
|
|
|
if value in (None, ""):
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(value, (int, float)):
|
|
|
|
|
try:
|
|
|
|
|
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return None
|
|
|
|
|
if text.endswith("Z"):
|
|
|
|
|
text = text[:-1] + "+00:00"
|
|
|
|
|
try:
|
|
|
|
|
dt = datetime.fromisoformat(text)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None
|
|
|
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 _entry_exhausted_ttl_seconds(error_code):
|
|
|
|
|
code = str(error_code or "").strip()
|
|
|
|
|
if code == "401":
|
|
|
|
|
return 5 * 60
|
|
|
|
|
return 60 * 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_pool_exhausted_until(entry):
|
|
|
|
|
if str(_entry_value(entry, "last_status") or "").strip().lower() != "exhausted":
|
|
|
|
|
return None
|
|
|
|
|
reset_at = _parse_dt(getattr(entry, "last_error_reset_at", None))
|
|
|
|
|
if reset_at is not None:
|
|
|
|
|
return reset_at
|
|
|
|
|
status_at = _parse_dt(getattr(entry, "last_status_at", None))
|
|
|
|
|
if status_at is None:
|
|
|
|
|
return None
|
|
|
|
|
return status_at + timedelta(seconds=_entry_exhausted_ttl_seconds(_entry_value(entry, "last_error_code")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_is_pool_exhausted(entry):
|
|
|
|
|
exhausted_until = _entry_pool_exhausted_until(entry)
|
|
|
|
|
return exhausted_until is not None and datetime.now(timezone.utc) < exhausted_until
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_entry_label(entry, index):
|
|
|
|
|
label = _entry_value(entry, "label", "source") or ""
|
|
|
|
|
if not label:
|
|
|
|
|
label = "Credential " + str(index)
|
|
|
|
|
label = " ".join(str(label).split())
|
|
|
|
|
if len(label) > 64:
|
|
|
|
|
label = label[:61].rstrip() + "..."
|
|
|
|
|
return label
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_pool_retry_after(entry):
|
|
|
|
|
return _iso(_entry_pool_exhausted_until(entry))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_pool_exhausted_reason(entry):
|
|
|
|
|
code = _entry_value(entry, "last_error_code")
|
|
|
|
|
reset_at = _entry_pool_retry_after(entry)
|
|
|
|
|
reason = "Credential pool marked this credential exhausted"
|
|
|
|
|
if code:
|
|
|
|
|
reason += " after provider status " + code
|
|
|
|
|
if reset_at:
|
|
|
|
|
reason += "; retry after " + reset_at
|
|
|
|
|
return reason + "."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _local_pool_snapshot(provider):
|
|
|
|
|
"""Probe-free pool snapshot from local auth.json entries.
|
|
|
|
|
|
|
|
|
|
Returns a SimpleNamespace compatible with _serialize_account_usage_snapshot,
|
|
|
|
|
or None if the provider has no pool or no entries.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
2026-06-20 23:52:52 +00:00
|
|
|
entries = [SimpleNamespace(**payload) for payload in _pool_entry_payloads(provider)]
|
2026-06-19 05:08:48 +00:00
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
if not entries:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
rows = []
|
|
|
|
|
available_count = 0
|
|
|
|
|
exhausted_count = 0
|
|
|
|
|
dead_count = 0
|
|
|
|
|
for index, entry in enumerate(entries, start=1):
|
|
|
|
|
label = _safe_entry_label(entry, index)
|
|
|
|
|
entry_status = str(_entry_value(entry, "last_status") or "").strip().lower()
|
|
|
|
|
if entry_status == "dead":
|
|
|
|
|
dead_count += 1
|
|
|
|
|
rows.append({
|
|
|
|
|
"label": label,
|
|
|
|
|
"status": "dead",
|
|
|
|
|
"plan": None,
|
|
|
|
|
"windows": [],
|
|
|
|
|
"details": [],
|
|
|
|
|
"unavailable_reason": "Credential permanently revoked or invalid.",
|
|
|
|
|
"retry_after": None,
|
|
|
|
|
"fetched_at": None,
|
|
|
|
|
})
|
|
|
|
|
elif _entry_is_pool_exhausted(entry):
|
|
|
|
|
exhausted_count += 1
|
|
|
|
|
rows.append({
|
|
|
|
|
"label": label,
|
|
|
|
|
"status": "exhausted",
|
|
|
|
|
"plan": None,
|
|
|
|
|
"windows": [],
|
|
|
|
|
"details": [],
|
|
|
|
|
"unavailable_reason": _entry_pool_exhausted_reason(entry),
|
|
|
|
|
"retry_after": _entry_pool_retry_after(entry),
|
|
|
|
|
"fetched_at": None,
|
|
|
|
|
})
|
|
|
|
|
else:
|
|
|
|
|
available_count += 1
|
|
|
|
|
rows.append({
|
|
|
|
|
"label": label,
|
|
|
|
|
"status": "available",
|
|
|
|
|
"plan": None,
|
|
|
|
|
"windows": [],
|
|
|
|
|
"details": [],
|
|
|
|
|
"unavailable_reason": None,
|
|
|
|
|
"retry_after": None,
|
|
|
|
|
"fetched_at": None,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if not rows:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
total = available_count + exhausted_count + dead_count
|
|
|
|
|
pool_dict = {
|
|
|
|
|
"total_credentials": total,
|
|
|
|
|
"queried_credentials": 0,
|
|
|
|
|
"available_credentials": available_count,
|
|
|
|
|
"exhausted_credentials": exhausted_count,
|
|
|
|
|
"dead_credentials": dead_count,
|
|
|
|
|
"failed_credentials": 0,
|
|
|
|
|
"plans": [],
|
|
|
|
|
"next_reset_at": None,
|
|
|
|
|
"best_remaining_by_window": [],
|
|
|
|
|
"credentials": rows,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
details = [str(available_count) + "/" + str(total) + " credentials available"]
|
|
|
|
|
if exhausted_count:
|
|
|
|
|
details.append(str(exhausted_count) + " exhausted")
|
|
|
|
|
if dead_count:
|
|
|
|
|
details.append(str(dead_count) + " dead")
|
|
|
|
|
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
provider=provider,
|
|
|
|
|
source="local_pool",
|
|
|
|
|
title="Credential pool",
|
|
|
|
|
plan=None,
|
|
|
|
|
windows=(),
|
|
|
|
|
details=tuple(details),
|
|
|
|
|
available=available_count > 0,
|
|
|
|
|
unavailable_reason=None if available_count > 0 else "All pool credentials are unavailable.",
|
|
|
|
|
fetched_at=datetime.now(timezone.utc),
|
|
|
|
|
pool=pool_dict,
|
|
|
|
|
)
|
2026-04-23 01:09:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 20:50:23 +00:00
|
|
|
def _decode_jwt_claims_unverified(token: str) -> dict[str, Any]:
|
2026-05-14 01:01:47 +00:00
|
|
|
"""Decode JWT claims for token-shape classification only.
|
|
|
|
|
|
|
|
|
|
The signature is intentionally not verified because this helper is not an
|
|
|
|
|
authorization decision: it only prevents a Codex OAuth JWT-shaped value from
|
|
|
|
|
being treated as a raw OpenAI API key in provider-card detection.
|
|
|
|
|
"""
|
2026-05-12 20:50:23 +00:00
|
|
|
if not isinstance(token, str) or token.count(".") != 2:
|
|
|
|
|
return {}
|
|
|
|
|
payload = token.split(".", 2)[1]
|
|
|
|
|
payload += "=" * ((4 - len(payload) % 4) % 4)
|
|
|
|
|
try:
|
|
|
|
|
claims = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8"))
|
|
|
|
|
except Exception:
|
|
|
|
|
return {}
|
|
|
|
|
return claims if isinstance(claims, dict) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_codex_oauth_token(value: str) -> bool:
|
|
|
|
|
"""Return True when a value is a ChatGPT/Codex OAuth JWT, not an OpenAI API key."""
|
|
|
|
|
token = str(value or "").strip()
|
|
|
|
|
if not token or token.startswith("sk-"):
|
|
|
|
|
return False
|
|
|
|
|
claims = _decode_jwt_claims_unverified(token)
|
|
|
|
|
if not claims:
|
|
|
|
|
return False
|
|
|
|
|
auth_claim = claims.get("https://api.openai.com/auth")
|
|
|
|
|
if isinstance(auth_claim, dict) and auth_claim:
|
|
|
|
|
return True
|
|
|
|
|
return any(key in claims for key in ("chatgpt_account_id", "https://api.openai.com/profile"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _provider_value_counts_as_api_key(provider_id: str, value: object) -> bool:
|
|
|
|
|
text = str(value or "").strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return False
|
|
|
|
|
if (provider_id or "").strip().lower() == "openai" and _looks_like_codex_oauth_token(text):
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _provider_has_shadowed_codex_oauth_value(provider_id: str) -> bool:
|
|
|
|
|
"""True when the bare OpenAI credential slot contains only a Codex OAuth JWT.
|
|
|
|
|
|
|
|
|
|
Users who authenticate Codex can end up with a ChatGPT/Codex JWT in a
|
|
|
|
|
legacy OPENAI_API_KEY-shaped location. That value should not make the bare
|
|
|
|
|
OpenAI API provider appear as configured, and the Providers tab should not
|
|
|
|
|
show an extra OpenAI card solely because of that Codex-only credential.
|
|
|
|
|
"""
|
|
|
|
|
if (provider_id or "").strip().lower() != "openai":
|
|
|
|
|
return False
|
|
|
|
|
values: list[object] = []
|
2026-06-05 18:35:26 +00:00
|
|
|
env_var = _provider_env_var_for(provider_id)
|
2026-05-12 20:50:23 +00:00
|
|
|
if env_var:
|
|
|
|
|
env_path = _get_hermes_home() / ".env"
|
|
|
|
|
env_values = _load_env_file(env_path)
|
|
|
|
|
values.append(env_values.get(env_var))
|
2026-06-20 06:24:07 +00:00
|
|
|
values.append(_thread_local_env_value(env_var))
|
2026-05-12 20:50:23 +00:00
|
|
|
for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or ():
|
|
|
|
|
values.append(env_values.get(alias))
|
2026-06-20 06:24:07 +00:00
|
|
|
values.append(_thread_local_env_value(alias))
|
2026-05-12 20:50:23 +00:00
|
|
|
|
|
|
|
|
cfg = get_config()
|
|
|
|
|
model_cfg = cfg.get("model", {})
|
|
|
|
|
if isinstance(model_cfg, dict):
|
|
|
|
|
active_provider = str(model_cfg.get("provider") or "").strip().lower()
|
|
|
|
|
if active_provider == provider_id:
|
|
|
|
|
values.append(model_cfg.get("api_key"))
|
2026-06-28 05:57:56 +00:00
|
|
|
providers_cfg = cfg.get("providers") or {}
|
2026-05-12 20:50:23 +00:00
|
|
|
if isinstance(providers_cfg, dict):
|
|
|
|
|
provider_cfg = providers_cfg.get(provider_id, {})
|
|
|
|
|
if isinstance(provider_cfg, dict):
|
|
|
|
|
values.append(provider_cfg.get("api_key"))
|
|
|
|
|
custom_providers = cfg.get("custom_providers", [])
|
|
|
|
|
if isinstance(custom_providers, list):
|
|
|
|
|
for cp in custom_providers:
|
|
|
|
|
if isinstance(cp, dict) and _custom_provider_name_matches(provider_id, cp.get("name")):
|
|
|
|
|
cp_key = cp.get("api_key")
|
|
|
|
|
if isinstance(cp_key, str) and cp_key.startswith("${") and cp_key.endswith("}"):
|
2026-06-20 06:24:07 +00:00
|
|
|
values.append(_thread_local_env_value(cp_key[2:-1]))
|
2026-05-12 20:50:23 +00:00
|
|
|
else:
|
|
|
|
|
values.append(cp_key)
|
|
|
|
|
return any(_looks_like_codex_oauth_token(str(value or "")) for value in values)
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 01:09:22 +00:00
|
|
|
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.
|
2026-04-27 22:28:19 +00:00
|
|
|
|
|
|
|
|
Preserves comments, blank lines, and original key order (#1164).
|
|
|
|
|
New keys are appended at the end of the file with a blank-line separator.
|
|
|
|
|
|
2026-04-23 02:09:37 +00:00
|
|
|
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.
|
2026-04-23 01:09:22 +00:00
|
|
|
"""
|
|
|
|
|
from api.streaming import _ENV_LOCK
|
2026-04-23 02:09:37 +00:00
|
|
|
import stat as _stat
|
2026-04-23 01:09:22 +00:00
|
|
|
|
2026-04-23 02:09:37 +00:00
|
|
|
with _ENV_LOCK:
|
2026-04-27 22:28:19 +00:00
|
|
|
# ── 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] = []
|
|
|
|
|
|
2026-04-23 02:09:37 +00:00
|
|
|
for key, value in updates.items():
|
|
|
|
|
if value is None:
|
2026-04-27 22:28:19 +00:00
|
|
|
# Mark the line for removal (None sentinel) and clear env.
|
2026-04-23 01:09:22 +00:00
|
|
|
os.environ.pop(key, None)
|
2026-04-27 22:28:19 +00:00
|
|
|
if key in existing_key_indices:
|
|
|
|
|
output_lines[existing_key_indices[key]] = None # type: ignore[assignment]
|
2026-04-23 02:09:37 +00:00
|
|
|
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.")
|
2026-04-23 01:09:22 +00:00
|
|
|
os.environ[key] = clean
|
|
|
|
|
|
2026-04-27 22:28:19 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-04-23 02:09:37 +00:00
|
|
|
env_path.parent.mkdir(parents=True, exist_ok=True)
|
2026-04-27 22:28:19 +00:00
|
|
|
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).
|
2026-04-23 02:09:37 +00:00
|
|
|
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
|
2026-04-27 22:28:19 +00:00
|
|
|
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
|
2026-04-23 02:09:37 +00:00
|
|
|
try:
|
|
|
|
|
env_path.chmod(_mode)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
2026-04-23 01:09:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-26 21:24:20 +00:00
|
|
|
3. ``config.yaml → model.api_key`` (only if provider is the active one)
|
2026-04-23 01:09:22 +00:00
|
|
|
4. ``config.yaml → providers.<id>.api_key``
|
|
|
|
|
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
|
|
|
|
|
"""
|
2026-06-05 18:35:26 +00:00
|
|
|
env_var = _provider_env_var_for(provider_id)
|
2026-04-23 01:09:22 +00:00
|
|
|
if env_var:
|
|
|
|
|
env_path = _get_hermes_home() / ".env"
|
|
|
|
|
env_values = _load_env_file(env_path)
|
2026-05-12 20:50:23 +00:00
|
|
|
env_file_value = env_values.get(env_var)
|
|
|
|
|
if _provider_value_counts_as_api_key(provider_id, env_file_value):
|
2026-04-23 01:09:22 +00:00
|
|
|
return True
|
2026-06-20 06:24:07 +00:00
|
|
|
env_value = _thread_local_env_value(env_var)
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, env_value):
|
2026-04-23 01:09:22 +00:00
|
|
|
return True
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
# 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 ():
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, env_values.get(alias)):
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
return True
|
2026-06-20 06:24:07 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, _thread_local_env_value(alias)):
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
return True
|
2026-06-16 17:44:10 +00:00
|
|
|
# Check credential pool — covers custom providers registered via
|
|
|
|
|
# `hermes auth add` which store keys in auth.json (not config.yaml).
|
|
|
|
|
# Must be outside the `if env_var:` block above: custom providers
|
|
|
|
|
# (custom:bothub, etc.) have no env var, so that block is skipped.
|
|
|
|
|
# Uses the cached _has_explicit_pool_credentials helper which also
|
|
|
|
|
# filters gh-cli / GITHUB_TOKEN ambient entries so copilot doesn't
|
|
|
|
|
# appear just because `gh` is installed.
|
|
|
|
|
try:
|
|
|
|
|
from api.config import _has_explicit_pool_credentials
|
|
|
|
|
if _has_explicit_pool_credentials(provider_id):
|
|
|
|
|
return True
|
|
|
|
|
except ImportError:
|
|
|
|
|
pass
|
2026-04-23 01:09:22 +00:00
|
|
|
|
|
|
|
|
cfg = get_config()
|
2026-04-26 21:24:20 +00:00
|
|
|
# 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.
|
2026-04-23 01:09:22 +00:00
|
|
|
model_cfg = cfg.get("model", {})
|
|
|
|
|
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
2026-04-26 21:24:20 +00:00
|
|
|
active_provider = model_cfg.get("provider")
|
|
|
|
|
if active_provider and str(active_provider).strip().lower() == provider_id.lower():
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, model_cfg.get("api_key")):
|
|
|
|
|
return True
|
2026-04-23 01:09:22 +00:00
|
|
|
# Check providers.<id>.api_key
|
2026-06-28 05:57:56 +00:00
|
|
|
providers_cfg = cfg.get("providers") or {}
|
2026-04-23 01:09:22 +00:00
|
|
|
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():
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, provider_cfg.get("api_key")):
|
|
|
|
|
return True
|
2026-04-23 01:09:22 +00:00
|
|
|
# Check custom_providers
|
|
|
|
|
custom_providers = cfg.get("custom_providers", [])
|
|
|
|
|
if isinstance(custom_providers, list):
|
|
|
|
|
for cp in custom_providers:
|
|
|
|
|
if isinstance(cp, dict):
|
2026-05-11 05:48:46 +00:00
|
|
|
if _custom_provider_name_matches(provider_id, cp.get("name")):
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, cp.get("api_key")):
|
2026-04-23 01:09:22 +00:00
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 00:04:18 +00:00
|
|
|
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()
|
2026-06-05 18:35:26 +00:00
|
|
|
env_var = _provider_env_var_for(provider_id)
|
2026-05-05 00:04:18 +00:00
|
|
|
if env_var:
|
|
|
|
|
env_path = _get_hermes_home() / ".env"
|
|
|
|
|
env_values = _load_env_file(env_path)
|
2026-05-12 20:50:23 +00:00
|
|
|
env_file_value = env_values.get(env_var)
|
|
|
|
|
if _provider_value_counts_as_api_key(provider_id, env_file_value):
|
|
|
|
|
return str(env_file_value).strip() or None
|
2026-06-20 06:24:07 +00:00
|
|
|
env_value = _thread_local_env_value(env_var)
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, env_value):
|
|
|
|
|
return str(env_value).strip() or None
|
2026-05-05 00:04:18 +00:00
|
|
|
for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or ():
|
2026-05-12 20:50:23 +00:00
|
|
|
alias_file_value = env_values.get(alias)
|
|
|
|
|
if _provider_value_counts_as_api_key(provider_id, alias_file_value):
|
|
|
|
|
return str(alias_file_value).strip() or None
|
2026-06-20 06:24:07 +00:00
|
|
|
alias_value = _thread_local_env_value(alias)
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, alias_value):
|
|
|
|
|
return str(alias_value).strip() or None
|
2026-05-05 00:04:18 +00:00
|
|
|
|
|
|
|
|
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()
|
2026-05-12 20:50:23 +00:00
|
|
|
if model_key and active_provider == provider_id and _provider_value_counts_as_api_key(provider_id, model_key):
|
2026-05-05 00:04:18 +00:00
|
|
|
return model_key
|
|
|
|
|
|
2026-06-28 05:57:56 +00:00
|
|
|
providers_cfg = cfg.get("providers") or {}
|
2026-05-05 00:04:18 +00:00
|
|
|
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()
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, provider_key):
|
2026-05-05 00:04:18 +00:00
|
|
|
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
|
2026-05-11 05:48:46 +00:00
|
|
|
if _custom_provider_name_matches(provider_id, cp.get("name")):
|
2026-05-05 00:04:18 +00:00
|
|
|
cp_key = str(cp.get("api_key") or "").strip()
|
|
|
|
|
if cp_key.startswith("${") and cp_key.endswith("}"):
|
2026-06-20 06:24:07 +00:00
|
|
|
return _thread_local_env_value(cp_key[2:-1]).strip() or None
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(provider_id, cp_key):
|
2026-05-05 00:04:18 +00:00
|
|
|
return cp_key
|
2026-06-16 17:44:10 +00:00
|
|
|
# Fallback: try credential pool (e.g. bothub key stored via auth.json)
|
2026-06-20 23:52:52 +00:00
|
|
|
for entry in _pool_entry_payloads(provider_id):
|
2026-06-21 01:42:51 +00:00
|
|
|
status = str(entry.get("last_status") or "").strip().lower()
|
|
|
|
|
if status == "dead":
|
|
|
|
|
continue
|
|
|
|
|
if status == "exhausted":
|
|
|
|
|
ns = SimpleNamespace(**entry)
|
|
|
|
|
if _entry_is_pool_exhausted(ns):
|
|
|
|
|
continue
|
2026-06-20 23:52:52 +00:00
|
|
|
key = str(
|
|
|
|
|
entry.get("runtime_api_key")
|
|
|
|
|
or entry.get("agent_key")
|
|
|
|
|
or entry.get("access_token")
|
|
|
|
|
or ""
|
|
|
|
|
).strip()
|
|
|
|
|
if key:
|
|
|
|
|
return key
|
2026-05-05 00:04:18 +00:00
|
|
|
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")),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-07 09:36:04 +00:00
|
|
|
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
|
2026-05-12 20:50:23 +00:00
|
|
|
result = {
|
2026-05-07 09:36:04 +00:00
|
|
|
"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)),
|
|
|
|
|
}
|
2026-05-12 20:50:23 +00:00
|
|
|
pool = getattr(snapshot, "pool", None)
|
|
|
|
|
if isinstance(pool, dict):
|
|
|
|
|
result["pool"] = pool
|
|
|
|
|
return result
|
2026-05-07 09:36:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 05:52:08 +00:00
|
|
|
def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None) -> dict[str, str]:
|
|
|
|
|
env = dict(os.environ)
|
2026-06-20 07:37:35 +00:00
|
|
|
try:
|
|
|
|
|
from api.config import _thread_ctx
|
|
|
|
|
except Exception:
|
|
|
|
|
_thread_ctx = None
|
|
|
|
|
if bool(getattr(_thread_ctx, "block_process_env_fallback", False)):
|
2026-06-22 19:19:56 +00:00
|
|
|
# Rely on the centralized profile scrub set (api.profiles), which unions
|
|
|
|
|
# the WebUI provider env vars + the agent auth registry + the non-registry
|
|
|
|
|
# agent credential fallback (CUSTOM_API_KEY, AWS/Bedrock family). Falling
|
|
|
|
|
# back to the WebUI-only set keeps the probe fail-closed if that import
|
|
|
|
|
# fails. (#3961 — don't leave a partial local AWS set here.)
|
2026-06-21 00:50:13 +00:00
|
|
|
_strip = set(_PROVIDER_CREDENTIAL_ENV_VARS)
|
|
|
|
|
try:
|
|
|
|
|
from api.profiles import _profile_secret_env_names, get_active_hermes_home
|
|
|
|
|
_strip.update(_profile_secret_env_names(get_active_hermes_home()))
|
|
|
|
|
except Exception:
|
2026-06-22 19:19:56 +00:00
|
|
|
_strip.update({"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"})
|
2026-06-21 00:50:13 +00:00
|
|
|
for env_name in _strip:
|
2026-06-20 07:37:35 +00:00
|
|
|
env.pop(env_name, None)
|
2026-05-08 05:52:08 +00:00
|
|
|
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
|
|
|
|
|
|
2026-06-05 18:35:26 +00:00
|
|
|
env_var = _provider_env_var_for((provider or "").strip().lower())
|
2026-05-08 05:52:08 +00:00
|
|
|
if env_var and api_key:
|
|
|
|
|
env[env_var] = api_key
|
|
|
|
|
|
2026-05-07 09:36:04 +00:00
|
|
|
try:
|
2026-05-08 05:52:08 +00:00
|
|
|
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"),
|
2026-05-12 20:50:23 +00:00
|
|
|
pool=payload.get("pool") if isinstance(payload.get("pool"), dict) else None,
|
2026-05-08 05:52:08 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-07 19:07:13 +00:00
|
|
|
class _AccountUsageProbeWorker:
|
|
|
|
|
def __init__(self, home: Path):
|
|
|
|
|
self.home = Path(home)
|
|
|
|
|
self.last_used = time.monotonic()
|
|
|
|
|
self._lock = threading.RLock()
|
|
|
|
|
self._proc: subprocess.Popen[str] | None = None
|
2026-06-11 09:52:24 +00:00
|
|
|
self._closed = False
|
2026-06-07 19:07:13 +00:00
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
with self._lock:
|
|
|
|
|
proc = self._proc
|
|
|
|
|
self._proc = None
|
2026-06-11 09:52:24 +00:00
|
|
|
self._closed = True
|
2026-06-07 19:07:13 +00:00
|
|
|
self._close_process(proc)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _close_process(proc: subprocess.Popen[str] | None) -> None:
|
|
|
|
|
if proc is None:
|
|
|
|
|
return
|
|
|
|
|
for stream_name in ("stdin", "stdout"):
|
|
|
|
|
stream = getattr(proc, stream_name, None)
|
|
|
|
|
try:
|
|
|
|
|
if stream is not None:
|
|
|
|
|
stream.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
if proc.poll() is None:
|
|
|
|
|
proc.terminate()
|
|
|
|
|
try:
|
|
|
|
|
proc.wait(timeout=1.0)
|
|
|
|
|
except Exception:
|
|
|
|
|
proc.kill()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def fetch(self, provider: str, *, api_key: str | None = None) -> Any:
|
|
|
|
|
if not self._lock.acquire(blocking=False):
|
|
|
|
|
return _fetch_account_usage_once_for_home(provider, self.home, api_key=api_key)
|
|
|
|
|
try:
|
|
|
|
|
return self._fetch_locked(provider, api_key=api_key)
|
|
|
|
|
finally:
|
|
|
|
|
self._lock.release()
|
|
|
|
|
|
|
|
|
|
def _fetch_locked(self, provider: str, *, api_key: str | None = None) -> Any:
|
|
|
|
|
self.last_used = time.monotonic()
|
|
|
|
|
proc = self._ensure_process(provider)
|
|
|
|
|
if proc is None or proc.stdin is None or proc.stdout is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
request = json.dumps({
|
|
|
|
|
"provider": provider,
|
|
|
|
|
"api_key": api_key or "",
|
|
|
|
|
"env_var": _provider_env_var_for((provider or "").strip().lower()),
|
|
|
|
|
}) + "\n"
|
|
|
|
|
result: dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
def round_trip() -> None:
|
|
|
|
|
try:
|
|
|
|
|
proc.stdin.write(request)
|
|
|
|
|
proc.stdin.flush()
|
|
|
|
|
result["line"] = proc.stdout.readline()
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
result["error"] = exc
|
|
|
|
|
|
|
|
|
|
thread = threading.Thread(target=round_trip, daemon=True)
|
|
|
|
|
thread.start()
|
|
|
|
|
thread.join(_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS)
|
|
|
|
|
self.last_used = time.monotonic()
|
|
|
|
|
if thread.is_alive():
|
|
|
|
|
self.close()
|
|
|
|
|
thread.join(timeout=1.0)
|
|
|
|
|
logger.debug("Account usage worker for %s timed out", provider)
|
|
|
|
|
return None
|
|
|
|
|
if result.get("error") is not None:
|
|
|
|
|
exc = result["error"]
|
|
|
|
|
self.close()
|
|
|
|
|
logger.debug(
|
|
|
|
|
"Account usage worker for %s failed",
|
|
|
|
|
provider,
|
|
|
|
|
exc_info=(type(exc), exc, exc.__traceback__),
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
line = str(result.get("line") or "").strip()
|
|
|
|
|
if not line:
|
|
|
|
|
self.close()
|
|
|
|
|
logger.debug("Account usage worker for %s exited before responding", provider)
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(line)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
self.close()
|
|
|
|
|
logger.debug("Account usage worker for %s returned invalid JSON", provider)
|
|
|
|
|
return None
|
|
|
|
|
return _account_usage_payload_to_snapshot(payload)
|
|
|
|
|
|
|
|
|
|
def _ensure_process(self, provider: str) -> subprocess.Popen[str] | None:
|
|
|
|
|
if self._proc is not None and self._proc.poll() is None:
|
|
|
|
|
return self._proc
|
|
|
|
|
old_proc = self._proc
|
|
|
|
|
self._proc = None
|
|
|
|
|
self._close_process(old_proc)
|
|
|
|
|
try:
|
|
|
|
|
from api.config import PYTHON_EXE
|
|
|
|
|
except Exception:
|
|
|
|
|
PYTHON_EXE = sys.executable or "python3"
|
|
|
|
|
|
|
|
|
|
kwargs: dict[str, Any] = {
|
|
|
|
|
"stdin": subprocess.PIPE,
|
|
|
|
|
"stdout": subprocess.PIPE,
|
|
|
|
|
"stderr": subprocess.DEVNULL,
|
|
|
|
|
"text": True,
|
|
|
|
|
"bufsize": 1,
|
|
|
|
|
}
|
|
|
|
|
if hasattr(os, "fork"): # POSIX
|
|
|
|
|
kwargs["preexec_fn"] = _account_usage_preexec_fn
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self._proc = subprocess.Popen(
|
|
|
|
|
[
|
|
|
|
|
PYTHON_EXE,
|
|
|
|
|
"-c",
|
|
|
|
|
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
|
|
|
|
|
"--worker",
|
|
|
|
|
],
|
|
|
|
|
env=_account_usage_subprocess_env(self.home, provider, None),
|
|
|
|
|
**kwargs,
|
|
|
|
|
)
|
2026-06-13 20:42:43 +00:00
|
|
|
self._closed = False
|
2026-06-07 19:07:13 +00:00
|
|
|
except Exception:
|
|
|
|
|
self._proc = None
|
|
|
|
|
logger.debug("Account usage worker for %s failed to launch", provider, exc_info=True)
|
|
|
|
|
return self._proc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _launch_account_usage_worker_process(
|
|
|
|
|
home: Path,
|
|
|
|
|
provider: str,
|
|
|
|
|
*,
|
|
|
|
|
stdin: Any = subprocess.PIPE,
|
|
|
|
|
stdout: Any = subprocess.PIPE,
|
|
|
|
|
) -> subprocess.Popen[str] | None:
|
|
|
|
|
try:
|
|
|
|
|
from api.config import PYTHON_EXE
|
|
|
|
|
except Exception:
|
|
|
|
|
PYTHON_EXE = sys.executable or "python3"
|
|
|
|
|
|
|
|
|
|
kwargs: dict[str, Any] = {
|
|
|
|
|
"stdin": stdin,
|
|
|
|
|
"stdout": stdout,
|
|
|
|
|
"stderr": subprocess.DEVNULL,
|
|
|
|
|
"text": True,
|
|
|
|
|
"bufsize": 1,
|
|
|
|
|
}
|
|
|
|
|
if hasattr(os, "fork"): # POSIX
|
|
|
|
|
kwargs["preexec_fn"] = _account_usage_preexec_fn
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return subprocess.Popen(
|
|
|
|
|
[
|
|
|
|
|
PYTHON_EXE,
|
|
|
|
|
"-c",
|
|
|
|
|
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
|
|
|
|
|
"--worker",
|
|
|
|
|
],
|
|
|
|
|
env=_account_usage_subprocess_env(home, provider, None),
|
|
|
|
|
**kwargs,
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.debug("Account usage worker for %s failed to launch", provider, exc_info=True)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_account_usage_once_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any:
|
|
|
|
|
proc = _launch_account_usage_worker_process(Path(home), provider)
|
|
|
|
|
if proc is None or proc.stdin is None or proc.stdout is None:
|
|
|
|
|
_AccountUsageProbeWorker._close_process(proc)
|
|
|
|
|
return None
|
|
|
|
|
request = json.dumps({
|
|
|
|
|
"provider": provider,
|
|
|
|
|
"api_key": api_key or "",
|
|
|
|
|
"env_var": _provider_env_var_for((provider or "").strip().lower()),
|
|
|
|
|
}) + "\n"
|
|
|
|
|
try:
|
|
|
|
|
stdout, _stderr = proc.communicate(request, timeout=_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
_AccountUsageProbeWorker._close_process(proc)
|
|
|
|
|
return None
|
|
|
|
|
except Exception:
|
|
|
|
|
_AccountUsageProbeWorker._close_process(proc)
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
line = str(stdout or "").splitlines()[0]
|
|
|
|
|
payload = json.loads(line.strip())
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
return None
|
|
|
|
|
except IndexError:
|
|
|
|
|
return None
|
|
|
|
|
return _account_usage_payload_to_snapshot(payload)
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 16:25:40 +00:00
|
|
|
def _get_account_usage_probe_worker(home: Path) -> "_AccountUsageProbeWorker | None":
|
2026-06-10 23:03:45 +00:00
|
|
|
"""Return a worker with its lock already held, or None if saturated.
|
|
|
|
|
|
|
|
|
|
The caller MUST release worker._lock after use (typically via try/finally).
|
|
|
|
|
Holding the lock across the handoff eliminates the TOCTOU window that would
|
|
|
|
|
let two concurrent probes both observe the same worker as free.
|
|
|
|
|
"""
|
2026-06-07 19:07:13 +00:00
|
|
|
key = str(Path(home))
|
2026-06-13 20:42:43 +00:00
|
|
|
stale: list[_AccountUsageProbeWorker] = []
|
|
|
|
|
claimed: _AccountUsageProbeWorker | None = None
|
2026-06-07 19:07:13 +00:00
|
|
|
with _account_usage_worker_pool_lock:
|
2026-06-13 20:42:43 +00:00
|
|
|
existing_workers = _account_usage_worker_pool.get(key)
|
|
|
|
|
workers: list[_AccountUsageProbeWorker]
|
|
|
|
|
if not existing_workers:
|
2026-06-10 16:25:40 +00:00
|
|
|
workers = [_AccountUsageProbeWorker(Path(home)) for _ in range(_ACCOUNT_USAGE_WORKERS_PER_HOME)]
|
2026-06-13 20:42:43 +00:00
|
|
|
else:
|
|
|
|
|
stale = [worker for worker in existing_workers if worker._closed]
|
|
|
|
|
workers = [worker for worker in existing_workers if not worker._closed]
|
|
|
|
|
while len(workers) < _ACCOUNT_USAGE_WORKERS_PER_HOME:
|
|
|
|
|
workers.append(_AccountUsageProbeWorker(Path(home)))
|
|
|
|
|
_account_usage_worker_pool[key] = workers
|
|
|
|
|
for worker in workers:
|
|
|
|
|
if worker._lock.acquire(blocking=False):
|
|
|
|
|
claimed = worker
|
|
|
|
|
break
|
|
|
|
|
for worker in stale:
|
|
|
|
|
worker.close()
|
|
|
|
|
return claimed
|
2026-06-07 19:07:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cleanup_account_usage_probe_workers(
|
|
|
|
|
*,
|
|
|
|
|
now: float | None = None,
|
|
|
|
|
idle_seconds: float = _ACCOUNT_USAGE_WORKER_IDLE_SECONDS,
|
|
|
|
|
) -> None:
|
|
|
|
|
cutoff = time.monotonic() if now is None else now
|
|
|
|
|
stale: list[tuple[str, _AccountUsageProbeWorker]] = []
|
|
|
|
|
with _account_usage_worker_pool_lock:
|
2026-06-10 16:25:40 +00:00
|
|
|
for key, workers in list(_account_usage_worker_pool.items()):
|
|
|
|
|
for worker in workers:
|
|
|
|
|
if worker._lock.acquire(blocking=False):
|
|
|
|
|
try:
|
|
|
|
|
proc = worker._proc
|
2026-06-11 09:52:24 +00:00
|
|
|
is_dead = worker._closed or (proc is not None and proc.poll() is not None)
|
2026-06-10 16:25:40 +00:00
|
|
|
if is_dead or cutoff - worker.last_used >= idle_seconds:
|
|
|
|
|
stale.append((key, worker))
|
|
|
|
|
finally:
|
|
|
|
|
worker._lock.release()
|
|
|
|
|
remaining_workers = [w for w in workers if not any(k == key and w == sw for k, sw in stale)]
|
|
|
|
|
if not remaining_workers:
|
|
|
|
|
_account_usage_worker_pool.pop(key, None)
|
|
|
|
|
else:
|
2026-06-10 23:22:45 +00:00
|
|
|
# Replenish to N so partial cleanup doesn't permanently shrink the pool
|
|
|
|
|
while len(remaining_workers) < _ACCOUNT_USAGE_WORKERS_PER_HOME:
|
|
|
|
|
remaining_workers.append(_AccountUsageProbeWorker(Path(key)))
|
2026-06-10 16:25:40 +00:00
|
|
|
_account_usage_worker_pool[key] = remaining_workers
|
2026-06-07 19:07:13 +00:00
|
|
|
for _key, worker in stale:
|
|
|
|
|
worker.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _close_account_usage_probe_workers() -> None:
|
|
|
|
|
with _account_usage_worker_pool_lock:
|
2026-06-10 16:25:40 +00:00
|
|
|
workers = [w for wlist in _account_usage_worker_pool.values() for w in wlist]
|
2026-06-07 19:07:13 +00:00
|
|
|
_account_usage_worker_pool.clear()
|
|
|
|
|
_close_account_usage_probe_worker_list(workers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _close_account_usage_probe_worker_list(workers: list[_AccountUsageProbeWorker]) -> None:
|
|
|
|
|
for worker in workers:
|
|
|
|
|
worker.close()
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 16:25:40 +00:00
|
|
|
def _close_account_usage_probe_workers_async(*, provider_id: str | None = None) -> None:
|
2026-06-07 19:07:13 +00:00
|
|
|
with _account_usage_worker_pool_lock:
|
2026-06-10 16:25:40 +00:00
|
|
|
if provider_id:
|
|
|
|
|
active_home = str(_get_hermes_home())
|
|
|
|
|
workers_to_close = []
|
|
|
|
|
for key, wlist in list(_account_usage_worker_pool.items()):
|
|
|
|
|
if key == active_home:
|
|
|
|
|
workers_to_close.extend(wlist)
|
|
|
|
|
_account_usage_worker_pool.pop(key, None)
|
|
|
|
|
else:
|
|
|
|
|
workers_to_close = [w for wlist in _account_usage_worker_pool.values() for w in wlist]
|
|
|
|
|
_account_usage_worker_pool.clear()
|
|
|
|
|
if not workers_to_close:
|
2026-06-07 19:07:13 +00:00
|
|
|
return
|
|
|
|
|
thread = threading.Thread(
|
|
|
|
|
target=_close_account_usage_probe_worker_list,
|
2026-06-10 16:25:40 +00:00
|
|
|
args=(workers_to_close,),
|
2026-06-07 19:07:13 +00:00
|
|
|
daemon=True,
|
|
|
|
|
name="account-usage-worker-close",
|
|
|
|
|
)
|
|
|
|
|
thread.start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
atexit.register(_close_account_usage_probe_workers)
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 20:50:23 +00:00
|
|
|
def _account_usage_cache_key(provider: str, home: Path, api_key: str | None) -> tuple[str, str, str]:
|
|
|
|
|
key_fingerprint = ""
|
|
|
|
|
if api_key:
|
|
|
|
|
key_fingerprint = hashlib.sha256(api_key.encode("utf-8", "ignore")).hexdigest()
|
|
|
|
|
return ((provider or "").strip().lower(), str(Path(home)), key_fingerprint)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_cached_account_usage(cache_key: tuple[str, str, str]) -> tuple[bool, Any]:
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
with _account_usage_status_cache_lock:
|
|
|
|
|
cached = _account_usage_status_cache.get(cache_key)
|
|
|
|
|
if cached is None:
|
|
|
|
|
return False, None
|
|
|
|
|
fetched_at, snapshot = cached
|
|
|
|
|
if now - fetched_at <= _ACCOUNT_USAGE_CACHE_TTL_SECONDS:
|
|
|
|
|
return True, snapshot
|
|
|
|
|
_account_usage_status_cache.pop(cache_key, None)
|
|
|
|
|
return False, None
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 03:00:24 +00:00
|
|
|
def invalidate_account_usage_status_cache(provider_id: str | None = None) -> None:
|
|
|
|
|
normalized = str(provider_id or "").strip().lower()
|
|
|
|
|
with _account_usage_status_cache_lock:
|
|
|
|
|
if not normalized:
|
|
|
|
|
_account_usage_status_cache.clear()
|
2026-06-07 19:07:13 +00:00
|
|
|
else:
|
|
|
|
|
for key in list(_account_usage_status_cache):
|
|
|
|
|
if key[0] == normalized:
|
|
|
|
|
_account_usage_status_cache.pop(key, None)
|
2026-06-10 16:25:40 +00:00
|
|
|
_close_account_usage_probe_workers_async(provider_id=normalized or None)
|
2026-05-13 03:00:24 +00:00
|
|
|
|
|
|
|
|
|
2026-05-13 03:16:34 +00:00
|
|
|
def _set_cached_account_usage(
|
|
|
|
|
cache_key: tuple[str, str, str],
|
|
|
|
|
snapshot: Any,
|
|
|
|
|
) -> None:
|
2026-05-12 20:50:23 +00:00
|
|
|
now = time.monotonic()
|
|
|
|
|
with _account_usage_status_cache_lock:
|
2026-05-14 01:01:47 +00:00
|
|
|
if snapshot is None:
|
2026-05-13 03:16:34 +00:00
|
|
|
cached = _account_usage_status_cache.get(cache_key)
|
|
|
|
|
if cached is not None and cached[1] is not None:
|
|
|
|
|
return
|
2026-05-14 01:01:47 +00:00
|
|
|
_account_usage_status_cache.pop(cache_key, None)
|
|
|
|
|
return
|
2026-05-12 20:50:23 +00:00
|
|
|
_account_usage_status_cache[cache_key] = (now, snapshot)
|
|
|
|
|
expired = [
|
|
|
|
|
key for key, (fetched_at, _snapshot) in _account_usage_status_cache.items()
|
|
|
|
|
if now - fetched_at > _ACCOUNT_USAGE_CACHE_TTL_SECONDS
|
|
|
|
|
]
|
|
|
|
|
for key in expired:
|
|
|
|
|
_account_usage_status_cache.pop(key, None)
|
|
|
|
|
while len(_account_usage_status_cache) > _ACCOUNT_USAGE_CACHE_MAX_ENTRIES:
|
|
|
|
|
oldest_key = min(
|
|
|
|
|
_account_usage_status_cache,
|
|
|
|
|
key=lambda key: _account_usage_status_cache[key][0],
|
|
|
|
|
)
|
|
|
|
|
_account_usage_status_cache.pop(oldest_key, None)
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 05:52:08 +00:00
|
|
|
def _agent_fetch_account_usage_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any:
|
|
|
|
|
try:
|
2026-06-07 19:07:13 +00:00
|
|
|
_cleanup_account_usage_probe_workers()
|
2026-06-10 16:25:40 +00:00
|
|
|
worker = _get_account_usage_probe_worker(home)
|
|
|
|
|
if worker is not None:
|
2026-06-10 23:03:45 +00:00
|
|
|
try:
|
|
|
|
|
return worker._fetch_locked(provider, api_key=api_key)
|
|
|
|
|
finally:
|
|
|
|
|
worker._lock.release()
|
2026-06-10 16:25:40 +00:00
|
|
|
return _fetch_account_usage_once_for_home(provider, home, api_key=api_key)
|
2026-05-08 05:52:08 +00:00
|
|
|
except Exception:
|
2026-06-07 19:07:13 +00:00
|
|
|
logger.debug("Account usage probe for %s failed", provider, exc_info=True)
|
2026-05-08 05:52:08 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 03:00:24 +00:00
|
|
|
def _fetch_account_usage_with_profile_context(provider: str, *, refresh: bool = False) -> Any:
|
2026-05-10 19:18:02 +00:00
|
|
|
"""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.
|
|
|
|
|
|
2026-06-07 19:07:13 +00:00
|
|
|
Warm per-profile worker processes handle the actual probe requests.
|
2026-05-10 19:18:02 +00:00
|
|
|
"""
|
2026-05-07 09:36:04 +00:00
|
|
|
home = _get_hermes_home()
|
|
|
|
|
api_key = _get_provider_api_key(provider)
|
2026-05-12 20:50:23 +00:00
|
|
|
cache_key = _account_usage_cache_key(provider, home, api_key)
|
2026-05-13 03:00:24 +00:00
|
|
|
if not refresh:
|
|
|
|
|
cache_hit, cached = _get_cached_account_usage(cache_key)
|
|
|
|
|
if cache_hit:
|
|
|
|
|
return cached
|
2026-05-10 19:18:02 +00:00
|
|
|
sem = _get_account_usage_probe_semaphore()
|
2026-05-07 09:36:04 +00:00
|
|
|
try:
|
2026-05-10 19:18:02 +00:00
|
|
|
with sem:
|
2026-05-12 20:50:23 +00:00
|
|
|
snapshot = _agent_fetch_account_usage_for_home(
|
2026-05-10 19:18:02 +00:00
|
|
|
provider,
|
|
|
|
|
home,
|
|
|
|
|
api_key=api_key,
|
|
|
|
|
)
|
2026-05-14 01:01:47 +00:00
|
|
|
_set_cached_account_usage(cache_key, snapshot)
|
2026-05-12 20:50:23 +00:00
|
|
|
return snapshot
|
2026-05-07 09:36:04 +00:00
|
|
|
except Exception:
|
|
|
|
|
logger.debug("Failed to fetch account usage for %s", provider, exc_info=True)
|
2026-05-14 01:01:47 +00:00
|
|
|
_set_cached_account_usage(cache_key, None)
|
2026-05-07 09:36:04 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 03:00:24 +00:00
|
|
|
def _provider_account_usage_status(provider: str, display_name: str, *, refresh: bool = False) -> dict[str, Any]:
|
|
|
|
|
snapshot = _fetch_account_usage_with_profile_context(provider, refresh=refresh)
|
2026-05-07 09:36:04 +00:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 03:00:24 +00:00
|
|
|
def get_provider_quota(provider_id: str | None = None, *, refresh: bool = False) -> dict[str, Any]:
|
2026-05-05 00:04:18 +00:00
|
|
|
"""Return sanitized quota/rate-limit status for the active provider.
|
|
|
|
|
|
2026-05-07 09:36:04 +00:00
|
|
|
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.
|
2026-05-05 00:04:18 +00:00
|
|
|
"""
|
|
|
|
|
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())
|
2026-05-07 09:36:04 +00:00
|
|
|
if provider in _ACCOUNT_USAGE_PROVIDERS:
|
2026-05-13 03:00:24 +00:00
|
|
|
return _provider_account_usage_status(provider, display_name, refresh=refresh)
|
2026-05-07 09:36:04 +00:00
|
|
|
|
2026-06-19 05:08:48 +00:00
|
|
|
if provider == "openrouter":
|
|
|
|
|
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",
|
|
|
|
|
},
|
2026-05-05 00:04:18 +00:00
|
|
|
)
|
2026-06-19 05:08:48 +00:00
|
|
|
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.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
local_snapshot = _local_pool_snapshot(provider)
|
|
|
|
|
if local_snapshot is not None:
|
|
|
|
|
account_limits = _serialize_account_usage_snapshot(local_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 "Credential pool",
|
|
|
|
|
"quota": None,
|
|
|
|
|
"account_limits": account_limits,
|
|
|
|
|
"message": f"{display_name} credential pool status loaded.",
|
|
|
|
|
}
|
2026-05-05 00:04:18 +00:00
|
|
|
return {
|
|
|
|
|
"ok": False,
|
2026-06-19 05:08:48 +00:00
|
|
|
"provider": provider,
|
2026-05-05 00:04:18 +00:00
|
|
|
"display_name": display_name,
|
|
|
|
|
"supported": True,
|
|
|
|
|
"status": "unavailable",
|
|
|
|
|
"quota": None,
|
2026-06-19 05:08:48 +00:00
|
|
|
"account_limits": account_limits,
|
|
|
|
|
"message": f"{display_name} credential pool: all credentials are unavailable.",
|
2026-05-05 00:04:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 05:08:48 +00:00
|
|
|
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}",
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 00:04:18 +00:00
|
|
|
|
2026-04-23 01:09:22 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 14:42:33 +00:00
|
|
|
# ── OpenRouter cost-history snapshot helpers (#692) ──────────────────────────
|
|
|
|
|
|
|
|
|
|
_COST_SNAPSHOTS_DIR_NAME = "cost-snapshots"
|
|
|
|
|
_COST_SNAPSHOT_MAX_DAYS = 365 # hard cap to prevent unbounded growth
|
2026-05-16 00:17:48 +00:00
|
|
|
_COST_SNAPSHOT_LOCK = threading.Lock()
|
2026-05-13 14:42:33 +00:00
|
|
|
|
|
|
|
|
|
2026-06-28 14:10:35 +00:00
|
|
|
def _get_provider_cost_budget() -> float | None:
|
|
|
|
|
"""Return the user-configured monthly spend budget, or None if unset."""
|
|
|
|
|
try:
|
|
|
|
|
from api.config import load_settings
|
|
|
|
|
raw = load_settings().get("provider_cost_budget")
|
2026-06-29 05:59:51 +00:00
|
|
|
return _coerce_provider_cost_budget(raw)
|
2026-06-28 14:10:35 +00:00
|
|
|
except Exception:
|
2026-06-29 05:59:51 +00:00
|
|
|
return None
|
2026-06-28 14:10:35 +00:00
|
|
|
|
|
|
|
|
|
2026-05-13 14:42:33 +00:00
|
|
|
def _cost_snapshots_dir() -> Path:
|
|
|
|
|
"""Return the directory for cost-snapshot JSON files.
|
|
|
|
|
|
|
|
|
|
Uses the Hermes home directory (profile-aware) so snapshots are
|
|
|
|
|
isolated per profile, matching the existing STATE_DIR convention.
|
|
|
|
|
"""
|
|
|
|
|
return _get_hermes_home() / _COST_SNAPSHOTS_DIR_NAME
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 02:49:15 +00:00
|
|
|
@contextmanager
|
|
|
|
|
def _cost_snapshot_file_lock(provider: str):
|
|
|
|
|
"""Serialize cost snapshot read-modify-write across worker processes."""
|
|
|
|
|
if fcntl is None:
|
|
|
|
|
with nullcontext():
|
|
|
|
|
yield
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
snap_dir = _cost_snapshots_dir()
|
|
|
|
|
snap_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
lock_path = snap_dir / f"{provider}.lock"
|
|
|
|
|
with lock_path.open("a", encoding="utf-8") as lock_file:
|
|
|
|
|
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
|
|
|
|
yield
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 14:42:33 +00:00
|
|
|
def _fetch_openrouter_key_usage(api_key: str) -> dict[str, Any] | None:
|
|
|
|
|
"""Fetch current usage/limit from the OpenRouter ``/auth/key`` endpoint.
|
|
|
|
|
|
|
|
|
|
Returns a dict with ``usage``, ``limit``, ``label`` on success, or
|
|
|
|
|
``None`` on any failure. Never raises; callers handle the None case.
|
|
|
|
|
"""
|
|
|
|
|
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 raw)
|
|
|
|
|
sanitized = _sanitize_openrouter_quota(payload)
|
|
|
|
|
label = None
|
|
|
|
|
if isinstance(payload, dict):
|
|
|
|
|
data = payload.get("data", payload)
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
label = str(data.get("label") or "").strip() or None
|
|
|
|
|
return {
|
|
|
|
|
"usage": sanitized.get("usage"),
|
|
|
|
|
"limit": sanitized.get("limit"),
|
|
|
|
|
"label": label,
|
|
|
|
|
}
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.debug("OpenRouter key usage fetch failed for cost-history", exc_info=True)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_cost_snapshots(provider: str) -> list[dict[str, Any]]:
|
|
|
|
|
"""Read persisted daily snapshots for *provider* from disk.
|
|
|
|
|
|
|
|
|
|
Returns a list of ``{date, used, limit}`` dicts sorted by date
|
|
|
|
|
ascending. Returns an empty list if the file does not exist or is
|
|
|
|
|
corrupt.
|
|
|
|
|
"""
|
|
|
|
|
path = _cost_snapshots_dir() / f"{provider}.json"
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return []
|
|
|
|
|
try:
|
|
|
|
|
raw = path.read_text(encoding="utf-8")
|
|
|
|
|
data = json.loads(raw)
|
|
|
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
|
|
|
return []
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
return []
|
|
|
|
|
snapshots = data.get("snapshots")
|
|
|
|
|
if not isinstance(snapshots, list):
|
|
|
|
|
return []
|
|
|
|
|
# Validate and sort
|
|
|
|
|
valid = []
|
|
|
|
|
for entry in snapshots:
|
|
|
|
|
if not isinstance(entry, dict):
|
|
|
|
|
continue
|
|
|
|
|
date = str(entry.get("date") or "").strip()
|
|
|
|
|
if not date:
|
|
|
|
|
continue
|
|
|
|
|
valid.append({
|
|
|
|
|
"date": date,
|
|
|
|
|
"used": _quota_number(entry.get("used")),
|
|
|
|
|
"limit": _quota_number(entry.get("limit")),
|
|
|
|
|
})
|
|
|
|
|
valid.sort(key=lambda e: e["date"])
|
|
|
|
|
return valid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_cost_snapshots(provider: str, snapshots: list[dict[str, Any]]) -> None:
|
|
|
|
|
"""Persist daily snapshots for *provider* to disk atomically."""
|
|
|
|
|
snap_dir = _cost_snapshots_dir()
|
|
|
|
|
snap_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
path = snap_dir / f"{provider}.json"
|
|
|
|
|
payload = {"provider": provider, "snapshots": snapshots}
|
|
|
|
|
body = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
|
|
|
import tempfile as _tempfile
|
|
|
|
|
_tmp_fd, _tmp_path = _tempfile.mkstemp(
|
|
|
|
|
dir=str(snap_dir), prefix=f".{provider}_", suffix=".tmp"
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
|
|
|
|
|
_f.write(body)
|
|
|
|
|
_f.flush()
|
|
|
|
|
os.fsync(_f.fileno())
|
|
|
|
|
os.replace(_tmp_path, path)
|
|
|
|
|
except BaseException:
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(_tmp_path)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _append_cost_snapshot(provider: str, usage: int | float | None, limit: int | float | None) -> list[dict[str, Any]]:
|
|
|
|
|
"""Append today's snapshot and return the updated list.
|
|
|
|
|
|
|
|
|
|
If a snapshot for today already exists it is updated in-place so
|
|
|
|
|
repeated calls within the same day are idempotent.
|
|
|
|
|
"""
|
|
|
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
2026-05-16 00:17:48 +00:00
|
|
|
# Serialize the read-modify-write cycle. The atomic os.replace in
|
2026-05-17 02:49:15 +00:00
|
|
|
# _write_cost_snapshots protects the file write itself, but without these
|
|
|
|
|
# locks two concurrent requests can both read the same old snapshot list and
|
|
|
|
|
# race to replace it with stale data. The threading lock covers current
|
|
|
|
|
# single-process deployments; the file lock covers future multi-worker
|
|
|
|
|
# deployments that share one Hermes home/state directory.
|
2026-05-16 00:17:48 +00:00
|
|
|
with _COST_SNAPSHOT_LOCK:
|
2026-05-17 02:49:15 +00:00
|
|
|
with _cost_snapshot_file_lock(provider):
|
|
|
|
|
snapshots = _read_cost_snapshots(provider)
|
|
|
|
|
# Update or append today's entry
|
|
|
|
|
updated = False
|
|
|
|
|
for entry in snapshots:
|
|
|
|
|
if entry["date"] == today:
|
|
|
|
|
entry["used"] = usage
|
|
|
|
|
entry["limit"] = limit
|
|
|
|
|
updated = True
|
|
|
|
|
break
|
|
|
|
|
if not updated:
|
|
|
|
|
snapshots.append({"date": today, "used": usage, "limit": limit})
|
|
|
|
|
snapshots.sort(key=lambda e: e["date"])
|
|
|
|
|
# Cap to _COST_SNAPSHOT_MAX_DAYS entries (keep most recent)
|
|
|
|
|
if len(snapshots) > _COST_SNAPSHOT_MAX_DAYS:
|
|
|
|
|
snapshots = snapshots[-_COST_SNAPSHOT_MAX_DAYS:]
|
|
|
|
|
_write_cost_snapshots(provider, snapshots)
|
|
|
|
|
return snapshots
|
2026-05-13 14:42:33 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compute_deltas(snapshots: list[dict[str, Any]], window_days: int) -> list[dict[str, Any]]:
|
|
|
|
|
"""Compute daily deltas from cumulative usage snapshots.
|
|
|
|
|
|
|
|
|
|
Each snapshot carries cumulative ``used``; the delta for a day is
|
|
|
|
|
the difference between that day's cumulative value and the previous
|
|
|
|
|
day's. The oldest day in the window has ``delta=None`` (no
|
2026-05-16 00:17:48 +00:00
|
|
|
previous baseline). If the cumulative value drops, treat that day
|
|
|
|
|
as the start of a fresh series (for example after an API-key rotation)
|
|
|
|
|
and use the current value as that day's delta instead of emitting a
|
|
|
|
|
negative spend bar.
|
2026-05-13 14:42:33 +00:00
|
|
|
"""
|
|
|
|
|
# Take only the last *window_days* entries
|
|
|
|
|
window = snapshots[-window_days:] if len(snapshots) > window_days else list(snapshots)
|
|
|
|
|
result: list[dict[str, Any]] = []
|
|
|
|
|
for i, entry in enumerate(window):
|
|
|
|
|
delta = None
|
|
|
|
|
if i > 0 and entry.get("used") is not None and window[i - 1].get("used") is not None:
|
|
|
|
|
delta = float(entry["used"]) - float(window[i - 1]["used"])
|
2026-05-16 00:17:48 +00:00
|
|
|
if delta < 0:
|
|
|
|
|
delta = float(entry["used"])
|
2026-05-13 14:42:33 +00:00
|
|
|
# Rounding: avoid -0.0 and tiny floating-point noise
|
|
|
|
|
if abs(delta) < 1e-9:
|
|
|
|
|
delta = 0.0
|
|
|
|
|
else:
|
|
|
|
|
delta = round(delta, 6)
|
|
|
|
|
result.append({
|
|
|
|
|
"date": entry["date"],
|
|
|
|
|
"used": entry.get("used"),
|
|
|
|
|
"delta": delta,
|
|
|
|
|
})
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_provider_cost_history(provider_id: str | None = None, days: int = 7) -> dict[str, Any]:
|
|
|
|
|
"""Return daily cost-history snapshots with deltas for a provider.
|
|
|
|
|
|
|
|
|
|
Currently only ``openrouter`` is supported. On each call the
|
|
|
|
|
endpoint fetches the current cumulative usage from the OpenRouter
|
|
|
|
|
``/auth/key`` endpoint, appends/updates today's snapshot, and
|
|
|
|
|
returns the last *days* snapshots with per-day deltas.
|
|
|
|
|
|
|
|
|
|
Returns a dict matching the existing API style (``ok``, ``provider``,
|
|
|
|
|
``status``, ``message``, …).
|
|
|
|
|
"""
|
|
|
|
|
provider = (provider_id or "").strip().lower()
|
|
|
|
|
if not provider:
|
|
|
|
|
return {
|
|
|
|
|
"ok": False,
|
|
|
|
|
"provider": None,
|
|
|
|
|
"status": "missing_provider",
|
|
|
|
|
"message": "Provider parameter is required. Use ?provider=openrouter",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if provider != "openrouter":
|
|
|
|
|
display_name = _PROVIDER_DISPLAY.get(provider, provider.replace("-", " ").title())
|
|
|
|
|
return {
|
|
|
|
|
"ok": False,
|
|
|
|
|
"provider": provider,
|
|
|
|
|
"display_name": display_name,
|
|
|
|
|
"supported": False,
|
|
|
|
|
"status": "unsupported",
|
|
|
|
|
"message": f"Cost history is not available for {display_name}. Only openrouter is supported in this release.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
display_name = _PROVIDER_DISPLAY.get("openrouter", "OpenRouter")
|
2026-06-28 14:10:35 +00:00
|
|
|
monthly_budget = _get_provider_cost_budget()
|
2026-05-13 14:42:33 +00:00
|
|
|
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",
|
2026-06-28 14:10:35 +00:00
|
|
|
"monthly_budget": monthly_budget,
|
2026-05-13 14:42:33 +00:00
|
|
|
"message": "OpenRouter cost history needs an OPENROUTER_API_KEY configured on the server.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Fetch current cumulative usage from OpenRouter
|
|
|
|
|
key_info = _fetch_openrouter_key_usage(api_key)
|
|
|
|
|
if key_info is None:
|
|
|
|
|
# Upstream failure — still return any previously persisted snapshots
|
|
|
|
|
# so the chart degrades gracefully instead of going blank.
|
|
|
|
|
snapshots = _read_cost_snapshots("openrouter")
|
|
|
|
|
deltas = _compute_deltas(snapshots, days)
|
|
|
|
|
return {
|
|
|
|
|
"ok": False,
|
|
|
|
|
"provider": "openrouter",
|
|
|
|
|
"display_name": display_name,
|
|
|
|
|
"supported": True,
|
|
|
|
|
"status": "unavailable",
|
|
|
|
|
"window_days": days,
|
|
|
|
|
"snapshots": deltas,
|
|
|
|
|
"limit": None,
|
|
|
|
|
"label": None,
|
2026-06-28 14:10:35 +00:00
|
|
|
"monthly_budget": monthly_budget,
|
2026-05-13 14:42:33 +00:00
|
|
|
"message": "OpenRouter cost history is temporarily unavailable. Showing last known data.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Persist today's snapshot
|
|
|
|
|
try:
|
|
|
|
|
snapshots = _append_cost_snapshot("openrouter", key_info["usage"], key_info["limit"])
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.debug("Failed to persist cost snapshot for openrouter", exc_info=True)
|
|
|
|
|
snapshots = _read_cost_snapshots("openrouter")
|
|
|
|
|
|
|
|
|
|
deltas = _compute_deltas(snapshots, days)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"provider": "openrouter",
|
|
|
|
|
"display_name": display_name,
|
|
|
|
|
"supported": True,
|
|
|
|
|
"status": "available",
|
|
|
|
|
"window_days": days,
|
|
|
|
|
"snapshots": deltas,
|
|
|
|
|
"limit": key_info.get("limit"),
|
|
|
|
|
"label": key_info.get("label") or "OpenRouter credits",
|
2026-06-28 14:10:35 +00:00
|
|
|
"monthly_budget": monthly_budget,
|
2026-05-13 14:42:33 +00:00
|
|
|
"message": "OpenRouter cost history loaded.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 01:09:22 +00:00
|
|
|
# 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())
|
2026-06-05 18:35:26 +00:00
|
|
|
known_ids.update(plugin_model_provider_ids())
|
2026-04-23 01:09:22 +00:00
|
|
|
|
|
|
|
|
# Also detect providers from config.yaml providers section
|
|
|
|
|
cfg = get_config()
|
2026-06-28 05:57:56 +00:00
|
|
|
providers_cfg = cfg.get("providers") or {}
|
2026-04-23 01:09:22 +00:00
|
|
|
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):
|
2026-06-05 18:35:26 +00:00
|
|
|
display_name = effective_provider_display_name(pid, _PROVIDER_DISPLAY)
|
2026-04-23 01:09:22 +00:00
|
|
|
is_oauth = _provider_is_oauth(pid)
|
|
|
|
|
has_key = _provider_has_key(pid)
|
2026-06-05 18:35:26 +00:00
|
|
|
plugin_auth_status: dict[str, Any] | None = None
|
|
|
|
|
if not has_key and is_plugin_model_provider(pid):
|
|
|
|
|
try:
|
|
|
|
|
from hermes_cli.auth import get_auth_status as _gas_plugin
|
|
|
|
|
_plugin_status = _gas_plugin(pid)
|
|
|
|
|
if isinstance(_plugin_status, dict) and (
|
|
|
|
|
_plugin_status.get("logged_in") or _plugin_status.get("configured")
|
|
|
|
|
):
|
|
|
|
|
has_key = True
|
|
|
|
|
plugin_auth_status = _plugin_status
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.debug("Plugin provider auth check failed for %s", pid, exc_info=True)
|
2026-04-23 01:09:22 +00:00
|
|
|
|
|
|
|
|
# Determine key source
|
|
|
|
|
key_source = "none"
|
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
|
|
|
auth_error = None
|
2026-04-23 01:09:22 +00:00
|
|
|
if is_oauth:
|
|
|
|
|
key_source = "oauth"
|
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
|
|
|
# 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).
|
2026-04-23 01:09:22 +00:00
|
|
|
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")
|
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
|
|
|
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
|
2026-04-23 01:09:22 +00:00
|
|
|
else:
|
|
|
|
|
has_key = False
|
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
|
|
|
auth_error = status.get("error") if isinstance(status, dict) else None
|
2026-04-23 01:09:22 +00:00
|
|
|
except Exception:
|
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
|
|
|
# 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()
|
2026-04-23 01:09:22 +00:00
|
|
|
elif has_key:
|
2026-06-05 18:35:26 +00:00
|
|
|
env_var = _provider_env_var_for(pid)
|
2026-04-23 01:09:22 +00:00
|
|
|
if env_var:
|
|
|
|
|
env_path = _get_hermes_home() / ".env"
|
|
|
|
|
env_values = _load_env_file(env_path)
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(pid, env_values.get(env_var)):
|
2026-04-23 01:09:22 +00:00
|
|
|
key_source = "env_file"
|
2026-06-20 06:24:07 +00:00
|
|
|
elif _provider_value_counts_as_api_key(pid, _thread_local_env_value(env_var)):
|
2026-04-23 01:09:22 +00:00
|
|
|
key_source = "env_var"
|
|
|
|
|
else:
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
# 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 ():
|
2026-05-12 20:50:23 +00:00
|
|
|
if _provider_value_counts_as_api_key(pid, env_values.get(alias)):
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
key_source = "env_file"
|
|
|
|
|
aliased = True
|
|
|
|
|
break
|
2026-06-20 06:24:07 +00:00
|
|
|
if _provider_value_counts_as_api_key(pid, _thread_local_env_value(alias)):
|
fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes #1499
Closes #1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
|
|
|
key_source = "env_var"
|
|
|
|
|
aliased = True
|
|
|
|
|
break
|
|
|
|
|
if not aliased:
|
2026-06-05 18:35:26 +00:00
|
|
|
_plugin_ks = (
|
|
|
|
|
str(plugin_auth_status.get("key_source") or "").strip()
|
|
|
|
|
if isinstance(plugin_auth_status, dict)
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
key_source = _plugin_ks or "config_yaml"
|
2026-04-23 01:09:22 +00:00
|
|
|
else:
|
2026-06-05 18:35:26 +00:00
|
|
|
_plugin_ks = (
|
|
|
|
|
str(plugin_auth_status.get("key_source") or "").strip()
|
|
|
|
|
if isinstance(plugin_auth_status, dict)
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
key_source = _plugin_ks or "config_yaml"
|
|
|
|
|
elif not _provider_env_var_for(pid):
|
2026-04-28 10:58:23 +00:00
|
|
|
# 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).
|
|
|
|
|
#
|
2026-06-05 18:35:26 +00:00
|
|
|
# IMPORTANT: we skip providers with a known API-key env var because
|
|
|
|
|
# they are pure API-key providers — calling get_auth_status() for
|
|
|
|
|
# every unconfigured API-key provider would add unnecessary latency
|
2026-04-28 10:58:23 +00:00
|
|
|
# (network round-trip per provider) on the Settings page.
|
2026-04-29 05:06:34 +00:00
|
|
|
# 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
|
2026-04-23 01:09:22 +00:00
|
|
|
|
2026-05-12 20:50:23 +00:00
|
|
|
if pid == "openai" and not has_key and _provider_has_shadowed_codex_oauth_value(pid):
|
|
|
|
|
continue
|
|
|
|
|
|
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
|
|
|
models = list(_PROVIDER_MODELS.get(pid, []))
|
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
|
|
|
models_total = len(models)
|
2026-05-07 17:58:52 +00:00
|
|
|
# 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.)
|
2026-05-07 12:41:14 +00:00
|
|
|
if pid == "openai-codex":
|
2026-05-07 17:58:52 +00:00
|
|
|
live_ids = _read_live_provider_model_ids("openai-codex")
|
2026-06-05 17:44:02 +00:00
|
|
|
live_id_set = set(live_ids)
|
2026-05-07 17:58:52 +00:00
|
|
|
for mid in _read_visible_codex_cache_model_ids():
|
2026-06-05 17:44:02 +00:00
|
|
|
if mid not in live_id_set:
|
|
|
|
|
live_id_set.add(mid)
|
2026-05-07 17:58:52 +00:00
|
|
|
live_ids.append(mid)
|
|
|
|
|
live_models = _models_from_live_provider_ids(pid, live_ids)
|
|
|
|
|
if live_models:
|
|
|
|
|
models = live_models
|
|
|
|
|
models_total = len(models)
|
2026-05-19 02:49:54 +00:00
|
|
|
if pid == "xai-oauth":
|
|
|
|
|
live_models = _models_from_live_provider_ids(
|
|
|
|
|
pid,
|
|
|
|
|
_read_live_provider_model_ids("xai-oauth"),
|
|
|
|
|
)
|
|
|
|
|
if live_models:
|
|
|
|
|
models = live_models
|
|
|
|
|
models_total = len(models)
|
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
|
|
|
# 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.
|
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
|
|
|
#
|
|
|
|
|
# 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).
|
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
|
|
|
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.
|
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
|
|
|
from api.config import _format_nous_label, _build_nous_featured_set
|
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
|
|
|
|
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
|
|
|
featured_ids, _extras = _build_nous_featured_set(live_ids)
|
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
|
|
|
models = [
|
|
|
|
|
{"id": f"@nous:{mid}", "label": _format_nous_label(mid)}
|
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
|
|
|
for mid in featured_ids
|
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
|
|
|
]
|
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
|
|
|
models_total = len(live_ids)
|
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
|
|
|
except Exception:
|
|
|
|
|
logger.debug("Failed to load Nous Portal models from hermes_cli")
|
2026-05-09 19:20:01 +00:00
|
|
|
# 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")
|
2026-06-05 18:35:26 +00:00
|
|
|
if is_plugin_model_provider(pid):
|
|
|
|
|
try:
|
|
|
|
|
live_models = _models_from_live_provider_ids(
|
|
|
|
|
pid,
|
|
|
|
|
_read_live_provider_model_ids(pid),
|
|
|
|
|
)
|
|
|
|
|
if live_models:
|
|
|
|
|
models = live_models
|
|
|
|
|
models_total = len(models)
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.debug(
|
|
|
|
|
"Failed to load plugin model-provider catalog for %s",
|
|
|
|
|
pid,
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-04-23 01:09:22 +00:00
|
|
|
# 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]
|
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
|
|
|
# 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)
|
2026-04-23 01:09:22 +00:00
|
|
|
|
2026-07-02 08:17:44 +00:00
|
|
|
is_self_hosted = pid in _SELF_HOSTED_PROVIDER_IDS
|
|
|
|
|
try:
|
|
|
|
|
from api.config import _get_provider_base_url
|
|
|
|
|
provider_base_url = _get_provider_base_url(pid) if is_self_hosted else None
|
|
|
|
|
except Exception:
|
|
|
|
|
provider_base_url = None
|
2026-06-05 18:35:26 +00:00
|
|
|
_is_plugin = is_plugin_model_provider(pid)
|
2026-04-23 01:09:22 +00:00
|
|
|
providers.append({
|
|
|
|
|
"id": pid,
|
|
|
|
|
"display_name": display_name,
|
|
|
|
|
"has_key": has_key,
|
2026-06-05 18:35:26 +00:00
|
|
|
"configurable": not is_oauth and bool(_provider_env_var_for(pid)),
|
2026-07-02 08:17:44 +00:00
|
|
|
"is_self_hosted": is_self_hosted,
|
|
|
|
|
"base_url": provider_base_url,
|
2026-06-05 18:35:26 +00:00
|
|
|
"is_plugin_provider": _is_plugin,
|
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
|
|
|
"is_oauth": is_oauth,
|
2026-04-23 01:09:22 +00:00
|
|
|
"key_source": key_source,
|
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
|
|
|
"auth_error": auth_error,
|
2026-04-23 01:09:22 +00:00
|
|
|
"models": models,
|
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
|
|
|
# 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,
|
2026-04-23 01:09:22 +00:00
|
|
|
})
|
|
|
|
|
|
2026-04-28 03:52:17 +00:00
|
|
|
# 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()
|
2026-05-11 05:48:46 +00:00
|
|
|
cp_id = _custom_provider_slug_from_name(cp_name)
|
|
|
|
|
if not cp_id:
|
2026-05-11 17:30:56 +00:00
|
|
|
logger.warning(
|
|
|
|
|
"Custom provider entry %r produced empty slug; skipping",
|
|
|
|
|
cp_name,
|
|
|
|
|
)
|
2026-05-11 05:48:46 +00:00
|
|
|
continue
|
2026-04-28 03:52:17 +00:00
|
|
|
# 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]
|
2026-06-20 06:24:07 +00:00
|
|
|
cp_has_key = bool(_thread_local_env_value(env_var).strip())
|
2026-06-16 17:44:10 +00:00
|
|
|
# Fallback: check credential pool (key added via hermes auth add)
|
|
|
|
|
if not cp_has_key:
|
|
|
|
|
try:
|
|
|
|
|
from api.config import _has_explicit_pool_credentials
|
|
|
|
|
if _has_explicit_pool_credentials(cp_id):
|
|
|
|
|
cp_has_key = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
pass
|
2026-04-28 03:52:17 +00:00
|
|
|
providers.append({
|
|
|
|
|
"id": cp_id,
|
|
|
|
|
"display_name": cp_name,
|
|
|
|
|
"has_key": cp_has_key,
|
|
|
|
|
"configurable": False, # custom providers managed via config.yaml
|
2026-05-20 13:22:46 +00:00
|
|
|
"is_custom": True,
|
2026-04-28 03:52:17 +00:00
|
|
|
"key_source": "config_yaml" if cp_has_key else "none",
|
|
|
|
|
"models": cp_models,
|
2026-05-20 13:22:46 +00:00
|
|
|
"models_total": len(cp_models),
|
2026-04-28 03:52:17 +00:00
|
|
|
})
|
|
|
|
|
|
2026-04-23 01:09:22 +00:00
|
|
|
# Determine active provider
|
|
|
|
|
active_provider = None
|
|
|
|
|
model_cfg = cfg.get("model", {})
|
|
|
|
|
if isinstance(model_cfg, dict):
|
|
|
|
|
active_provider = model_cfg.get("provider")
|
|
|
|
|
|
2026-05-21 07:38:03 +00:00
|
|
|
# Sort providers: active first, then custom:*, then has_key, then rest.
|
|
|
|
|
def _provider_sort_key(p):
|
|
|
|
|
pid = p.get("id") or ""
|
|
|
|
|
if pid == active_provider:
|
|
|
|
|
return (0, pid)
|
|
|
|
|
if pid.startswith("custom:"):
|
|
|
|
|
return (1, pid)
|
|
|
|
|
if p.get("has_key"):
|
|
|
|
|
return (2, pid)
|
|
|
|
|
return (3, pid)
|
|
|
|
|
providers.sort(key=_provider_sort_key)
|
|
|
|
|
|
2026-04-23 01:09:22 +00:00
|
|
|
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.",
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 18:35:26 +00:00
|
|
|
env_var = _provider_env_var_for(provider_id)
|
2026-04-23 01:09:22 +00:00
|
|
|
if not env_var:
|
|
|
|
|
return {
|
|
|
|
|
"ok": False,
|
2026-06-05 18:35:26 +00:00
|
|
|
"error": f"Cannot configure API key for '{effective_provider_display_name(provider_id, _PROVIDER_DISPLAY)}'. "
|
2026-04-23 01:09:22 +00:00
|
|
|
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()
|
2026-06-07 19:07:13 +00:00
|
|
|
invalidate_account_usage_status_cache(provider_id)
|
2026-04-23 01:09:22 +00:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
2026-04-26 21:24:20 +00:00
|
|
|
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.
|
2026-04-23 01:09:22 +00:00
|
|
|
"""
|
2026-04-26 21:24:20 +00:00
|
|
|
from api.config import _cfg_lock
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-04 06:13:10 +00:00
|
|
|
# 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()
|
2026-04-26 21:24:20 +00:00
|
|
|
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
|
2026-06-28 05:57:56 +00:00
|
|
|
providers_cfg = cfg.get("providers") or {}
|
2026-04-26 21:24:20 +00:00
|
|
|
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):
|
2026-05-11 05:48:46 +00:00
|
|
|
if _custom_provider_name_matches(provider_id, cp.get("name")):
|
2026-04-26 21:24:20 +00:00
|
|
|
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)
|