mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-14 15:07:45 +00:00
1003 lines
41 KiB
Python
1003 lines
41 KiB
Python
"""Hermes Web UI -- first-run onboarding helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import socket
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from api.auth import is_auth_enabled
|
|
from api.config import (
|
|
DEFAULT_MODEL,
|
|
DEFAULT_WORKSPACE,
|
|
_FALLBACK_MODELS,
|
|
_HERMES_FOUND,
|
|
_PROVIDER_DISPLAY,
|
|
_PROVIDER_MODELS,
|
|
_get_config_path,
|
|
get_available_models,
|
|
get_config,
|
|
load_settings,
|
|
reload_config,
|
|
save_settings,
|
|
verify_hermes_imports,
|
|
)
|
|
from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164)
|
|
from api.workspace import get_last_workspace, load_workspaces
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_SUPPORTED_PROVIDER_SETUPS = {
|
|
# ── Easy start ──────────────────────────────────────────────────────
|
|
"openrouter": {
|
|
"label": "OpenRouter",
|
|
"env_var": "OPENROUTER_API_KEY",
|
|
"default_model": "anthropic/claude-sonnet-4.6",
|
|
"requires_base_url": False,
|
|
"models": [
|
|
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
|
|
],
|
|
"category": "easy_start",
|
|
"quick": True,
|
|
},
|
|
"anthropic": {
|
|
"label": "Anthropic",
|
|
"env_var": "ANTHROPIC_API_KEY",
|
|
"default_model": "claude-sonnet-4.6",
|
|
"requires_base_url": False,
|
|
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
|
|
"category": "easy_start",
|
|
"oauth_provider": "anthropic",
|
|
"oauth_label": "Claude Code OAuth",
|
|
},
|
|
"openai": {
|
|
"label": "OpenAI",
|
|
"env_var": "OPENAI_API_KEY",
|
|
"default_model": "gpt-4o",
|
|
"default_base_url": "https://api.openai.com/v1",
|
|
"requires_base_url": False,
|
|
"models": list(_PROVIDER_MODELS.get("openai", [])),
|
|
"category": "easy_start",
|
|
},
|
|
# ── Open / self-hosted ─────────────────────────────────────────────
|
|
"ollama": {
|
|
"label": "Ollama",
|
|
"env_var": "OLLAMA_API_KEY",
|
|
"default_model": "qwen3:32b",
|
|
"default_base_url": "http://localhost:11434/v1",
|
|
"requires_base_url": True,
|
|
# Local Ollama runs keyless by default — only Ollama Cloud requires
|
|
# OLLAMA_API_KEY. The wizard accepts an empty api_key for this
|
|
# provider; users with auth enabled can still type one. See #1499.
|
|
"key_optional": True,
|
|
"models": [],
|
|
"category": "self_hosted",
|
|
},
|
|
"lmstudio": {
|
|
"label": "LM Studio",
|
|
# Canonical env var matches the agent CLI runtime (hermes_cli/auth.py:182,
|
|
# api_key_env_vars=("LM_API_KEY",)). Onboarding writes this name so the
|
|
# agent runtime actually picks up the key on the next chat — pre-#1499/#1500
|
|
# the WebUI wrote LMSTUDIO_API_KEY which the agent runtime ignored, masked
|
|
# in practice by the LMSTUDIO_NOAUTH_PLACEHOLDER fallback for keyless installs.
|
|
"env_var": "LM_API_KEY",
|
|
# Legacy env var written by older WebUI builds (≤ v0.50.272). Detection
|
|
# paths (_provider_api_key_present here, _provider_has_key in providers.py)
|
|
# also read this name so existing users with the old key in their .env
|
|
# don't flip to "no key" in Settings → Providers after upgrading.
|
|
# Onboarding only writes the canonical name going forward.
|
|
"env_var_aliases": ["LMSTUDIO_API_KEY"],
|
|
"default_model": "gpt-4o-mini",
|
|
"default_base_url": "http://localhost:1234/v1",
|
|
"requires_base_url": True,
|
|
# Most LM Studio installs run keyless (LMSTUDIO_NOAUTH_PLACEHOLDER on the
|
|
# agent side handles this). The wizard accepts an empty api_key; auth-
|
|
# enabled servers still need one but the user types it in the same field.
|
|
# See #1499 (third sub-bug from #1420).
|
|
"key_optional": True,
|
|
"models": [],
|
|
"category": "self_hosted",
|
|
},
|
|
"custom": {
|
|
"label": "Custom OpenAI-compatible",
|
|
"env_var": "OPENAI_API_KEY",
|
|
"default_model": "gpt-4o-mini",
|
|
"requires_base_url": True,
|
|
# Many self-hosted OpenAI-compatible servers (vLLM, llama-server,
|
|
# TabbyAPI, etc.) run keyless behind a private network. The wizard
|
|
# accepts an empty api_key — auth-protected endpoints can still
|
|
# supply one. See #1499.
|
|
"key_optional": True,
|
|
"models": [],
|
|
"category": "self_hosted",
|
|
},
|
|
# ── Specialized / extended ──────────────────────────────────────────
|
|
"gemini": {
|
|
"label": "Google Gemini",
|
|
"env_var": "GOOGLE_API_KEY",
|
|
"default_model": "gemini-3.1-pro-preview",
|
|
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
"requires_base_url": False,
|
|
# _PROVIDER_MODELS in api/config.py is keyed under "google" even though
|
|
# the agent's alias map normalizes "google" → "gemini". Use the catalog
|
|
# key here so the wizard surfaces the actual model list.
|
|
"models": list(_PROVIDER_MODELS.get("google", [])),
|
|
"category": "specialized",
|
|
},
|
|
"deepseek": {
|
|
"label": "DeepSeek",
|
|
"env_var": "DEEPSEEK_API_KEY",
|
|
"default_model": "deepseek-v4-flash",
|
|
"default_base_url": "https://api.deepseek.com",
|
|
"requires_base_url": False,
|
|
"models": list(_PROVIDER_MODELS.get("deepseek", [])),
|
|
"category": "specialized",
|
|
},
|
|
"xiaomi": {
|
|
"label": "Xiaomi MiMo",
|
|
"env_var": "XIAOMI_API_KEY",
|
|
"default_model": "mimo-v2.5-pro",
|
|
"default_base_url": "https://api.xiaomimimo.com/v1",
|
|
"requires_base_url": False,
|
|
"models": list(_PROVIDER_MODELS.get("xiaomi", [])),
|
|
"category": "specialized",
|
|
},
|
|
"zai": {
|
|
"label": "Z.AI / GLM (智谱)",
|
|
"env_var": "GLM_API_KEY",
|
|
"default_model": "glm-5.1",
|
|
"default_base_url": "https://open.bigmodel.cn/api/paas/v4",
|
|
"requires_base_url": False,
|
|
"models": list(_PROVIDER_MODELS.get("zai", [])),
|
|
"category": "specialized",
|
|
},
|
|
"nvidia": {
|
|
"label": "NVIDIA NIM",
|
|
"env_var": "NVIDIA_API_KEY",
|
|
"default_model": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
|
"default_base_url": "https://integrate.api.nvidia.com/v1",
|
|
"requires_base_url": False,
|
|
"models": list(_PROVIDER_MODELS.get("nvidia", [])),
|
|
"category": "specialized",
|
|
},
|
|
"mistralai": {
|
|
"label": "Mistral",
|
|
"env_var": "MISTRAL_API_KEY",
|
|
"default_model": "mistral-large-latest",
|
|
"default_base_url": "https://api.mistral.ai/v1",
|
|
"requires_base_url": False,
|
|
# No catalog entry for mistralai today — wizard shows a free-text input.
|
|
"models": list(_PROVIDER_MODELS.get("mistralai", [])),
|
|
"category": "specialized",
|
|
},
|
|
"x-ai": {
|
|
"label": "xAI (Grok)",
|
|
"env_var": "XAI_API_KEY",
|
|
"default_model": "grok-4.20",
|
|
"default_base_url": "https://api.x.ai/v1",
|
|
"requires_base_url": False,
|
|
# Agent normalizes "x-ai" → "xai"; _PROVIDER_MODELS is also keyed "xai"
|
|
# when populated, so check both keys for forward-compatibility.
|
|
"models": list(_PROVIDER_MODELS.get("xai", []) or _PROVIDER_MODELS.get("x-ai", [])),
|
|
"category": "specialized",
|
|
},
|
|
}
|
|
|
|
_PROVIDER_CATEGORIES = [
|
|
{"id": "easy_start", "label": "Easy start", "order": 0},
|
|
{"id": "self_hosted", "label": "Open / self-hosted", "order": 1},
|
|
{"id": "specialized", "label": "Specialized", "order": 2},
|
|
]
|
|
|
|
_UNSUPPORTED_PROVIDER_NOTE = (
|
|
"Advanced provider flows such as Nous Portal and GitHub Copilot are still "
|
|
"terminal-first. OpenAI Codex and Anthropic Claude Code can be authenticated in this onboarding flow "
|
|
"when your Hermes config selects the corresponding provider."
|
|
)
|
|
|
|
|
|
def _get_active_hermes_home() -> Path:
|
|
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]:
|
|
values: dict[str, str] = {}
|
|
if not env_path.exists():
|
|
return values
|
|
try:
|
|
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
except Exception:
|
|
return {}
|
|
return values
|
|
|
|
|
|
|
|
def _load_yaml_config(config_path: Path) -> dict:
|
|
try:
|
|
import yaml as _yaml
|
|
except ImportError:
|
|
return {}
|
|
|
|
if not config_path.exists():
|
|
return {}
|
|
try:
|
|
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
return loaded if isinstance(loaded, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save_yaml_config(config_path: Path, config: dict) -> None:
|
|
try:
|
|
import yaml as _yaml
|
|
except ImportError as exc:
|
|
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
|
|
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
config_path.write_text(
|
|
_yaml.safe_dump(config, sort_keys=False, allow_unicode=True),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _normalize_model_for_provider(provider: str, model: str) -> str:
|
|
clean = (model or "").strip()
|
|
if not clean:
|
|
return ""
|
|
if provider in {"anthropic", "openai"} and clean.startswith(provider + "/"):
|
|
return clean.split("/", 1)[1]
|
|
return clean
|
|
|
|
|
|
def _normalize_base_url(base_url: str) -> str:
|
|
return (base_url or "").strip().rstrip("/")
|
|
|
|
|
|
# ── Provider endpoint probe (#1499) ─────────────────────────────────────────
|
|
|
|
# Probe error codes — stable strings the frontend can switch on for inline
|
|
# error rendering. Add new codes only by extending this set; never reuse.
|
|
PROBE_ERROR_CODES = (
|
|
"invalid_url", # base_url failed urlparse / scheme / host check
|
|
"dns", # hostname did not resolve
|
|
"connect_refused", # TCP RST on connect (server not listening)
|
|
"timeout", # exceeded probe timeout
|
|
"http_4xx", # endpoint returned 4xx (auth required, wrong path, …)
|
|
"http_5xx", # endpoint returned 5xx (server-side fault)
|
|
"parse", # body not JSON or not the OpenAI /models shape
|
|
"unreachable", # other network / SSL / unknown error
|
|
)
|
|
|
|
PROBE_TIMEOUT_SECONDS = 5.0
|
|
# OpenAI /models response can list dozens of entries on Ollama / LM Studio.
|
|
# 256 KB is more than enough for any realistic catalog and bounds the worst
|
|
# case for a hostile / mis-pointed endpoint that streams forever.
|
|
PROBE_MAX_BYTES = 256 * 1024
|
|
|
|
|
|
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
"""Refuse to follow HTTP redirects on the probe path.
|
|
|
|
`urllib.request.urlopen` follows redirects by default — without this
|
|
handler, a probe at `http://example.com/v1/models` could be redirected
|
|
to `http://internal-service:8080/admin`, surfacing internal HTTP services
|
|
to whatever the probe targets next. The probe is already gated behind
|
|
WebUI auth and the local-network check, so the threat model is
|
|
"authenticated user enumerating internal services" — same as `curl`
|
|
from their browser DevTools. Disabling redirects tightens defaults
|
|
without breaking any legitimate use case (a self-hosted /models endpoint
|
|
that 3xx-redirects is itself misconfigured). Redirects surface to the
|
|
caller as `unreachable` (mapped from `HTTPError(3xx)` in the probe).
|
|
Reviewer-flagged in PR #1501 (#1499 + #1500).
|
|
"""
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
return None # tell urllib to NOT follow; raises HTTPError(3xx) instead
|
|
|
|
|
|
_PROBE_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
|
|
|
|
|
def probe_provider_endpoint(
|
|
provider: str,
|
|
base_url: str,
|
|
api_key: str | None = None,
|
|
timeout: float = PROBE_TIMEOUT_SECONDS,
|
|
) -> dict:
|
|
"""Probe `<base_url>/models` for a self-hosted OpenAI-compatible provider.
|
|
|
|
Used by the onboarding wizard to validate the user's configured base URL
|
|
before persisting (#1499). Distinguishes failure modes so the frontend
|
|
can render a precise inline error instead of a generic "could not save."
|
|
|
|
Returns one of:
|
|
|
|
{"ok": True, "models": [{"id": "...", "label": "..."}, ...]}
|
|
{"ok": False, "error": "<code>", "detail": "<human string>"}
|
|
|
|
Where ``<code>`` is one of ``PROBE_ERROR_CODES``.
|
|
|
|
The probe is a single HTTP GET — no retries. The timeout is short by
|
|
design: the wizard runs the probe synchronously on the user's submit
|
|
click, and we'd rather report "timeout" quickly than block the UI for
|
|
the kernel default ~75s.
|
|
|
|
The probe response is NOT persisted. This function returns model IDs
|
|
so the wizard can populate its dropdown, but ``apply_onboarding_setup``
|
|
only writes the user's typed selection — never auto-pinning a stale
|
|
list of models to ``config.yaml``.
|
|
|
|
SSRF: ``base_url`` is whatever the user typed in the onboarding form.
|
|
The wizard is gated behind authentication (post-onboarding, the user
|
|
has already authenticated to the WebUI), and the legitimate target is
|
|
a local LM Studio / Ollama / vLLM server, so we deliberately do not
|
|
block private-IP ranges — that would make the feature useless. The
|
|
risk surface is "authenticated user crafts a probe to enumerate
|
|
internal HTTP services," which is a different threat model from
|
|
unauthenticated SSRF.
|
|
"""
|
|
base_url = _normalize_base_url(base_url)
|
|
if not base_url:
|
|
return {"ok": False, "error": "invalid_url", "detail": "base_url is required"}
|
|
|
|
parsed = urlparse(base_url)
|
|
if parsed.scheme not in {"http", "https"}:
|
|
return {
|
|
"ok": False,
|
|
"error": "invalid_url",
|
|
"detail": "base_url must start with http:// or https://",
|
|
}
|
|
if not parsed.hostname:
|
|
return {"ok": False, "error": "invalid_url", "detail": "base_url has no host"}
|
|
|
|
# Build the probe URL. OpenAI-compatible servers expose /v1/models or
|
|
# /models. Most users supply a base URL ending in /v1, so we just append
|
|
# /models to whatever they typed. Strip the trailing slash and append
|
|
# rather than urljoin to avoid eating the /v1 segment when there's no
|
|
# trailing slash.
|
|
probe_url = f"{base_url}/models"
|
|
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"User-Agent": "hermes-webui-onboarding-probe",
|
|
}
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
req = urllib.request.Request(probe_url, headers=headers, method="GET")
|
|
|
|
try:
|
|
with _PROBE_OPENER.open(req, timeout=timeout) as resp:
|
|
status = resp.status
|
|
body = resp.read(PROBE_MAX_BYTES + 1)
|
|
except urllib.error.HTTPError as exc:
|
|
# 3xx / 4xx / 5xx with a body — categorize. 3xx happens when the
|
|
# endpoint redirects (we refuse to follow on the probe path — see
|
|
# _NoRedirectHandler). Map to `unreachable` rather than introducing a
|
|
# new error code, since a self-hosted /models endpoint that 3xx-
|
|
# redirects is itself misconfigured.
|
|
if 300 <= exc.code < 400:
|
|
code = "unreachable"
|
|
detail = (
|
|
f"HTTP {exc.code} — endpoint returned a redirect "
|
|
f"(probe does not follow redirects). Point base_url at the "
|
|
f"final URL directly."
|
|
)
|
|
return {"ok": False, "error": code, "detail": detail, "status": exc.code}
|
|
code = "http_4xx" if 400 <= exc.code < 500 else "http_5xx"
|
|
# Try to surface a useful detail (LM Studio sometimes returns text/plain).
|
|
try:
|
|
err_body = exc.read(2048).decode("utf-8", errors="replace").strip()
|
|
except Exception:
|
|
err_body = ""
|
|
detail = f"HTTP {exc.code}"
|
|
if err_body:
|
|
err_first = err_body.splitlines()[0][:200]
|
|
detail = f"{detail}: {err_first}"
|
|
return {"ok": False, "error": code, "detail": detail, "status": exc.code}
|
|
except urllib.error.URLError as exc:
|
|
# Distinguish DNS / connect-refused / timeout / generic.
|
|
reason = exc.reason
|
|
if isinstance(reason, socket.timeout) or "timed out" in str(reason).lower():
|
|
return {"ok": False, "error": "timeout", "detail": f"connection timed out after {timeout:g}s"}
|
|
if isinstance(reason, socket.gaierror):
|
|
return {
|
|
"ok": False,
|
|
"error": "dns",
|
|
"detail": f"could not resolve host '{parsed.hostname}'",
|
|
}
|
|
if isinstance(reason, ConnectionRefusedError) or "refused" in str(reason).lower():
|
|
port_hint = parsed.port or ("443" if parsed.scheme == "https" else "80")
|
|
return {
|
|
"ok": False,
|
|
"error": "connect_refused",
|
|
"detail": f"connection refused at {parsed.hostname}:{port_hint}",
|
|
}
|
|
return {"ok": False, "error": "unreachable", "detail": str(reason)[:200]}
|
|
except (TimeoutError, socket.timeout):
|
|
return {"ok": False, "error": "timeout", "detail": f"connection timed out after {timeout:g}s"}
|
|
except Exception as exc: # pragma: no cover — defensive net
|
|
logger.debug("probe_provider_endpoint unexpected error", exc_info=True)
|
|
return {"ok": False, "error": "unreachable", "detail": str(exc)[:200]}
|
|
|
|
# If the response was huge, refuse to parse. 256 KB cap is generous;
|
|
# anything bigger is likely the user pointed us at the wrong service.
|
|
if len(body) > PROBE_MAX_BYTES:
|
|
return {
|
|
"ok": False,
|
|
"error": "parse",
|
|
"detail": f"response exceeded {PROBE_MAX_BYTES // 1024} KB cap",
|
|
}
|
|
|
|
try:
|
|
payload = json.loads(body.decode("utf-8", errors="replace"))
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
return {
|
|
"ok": False,
|
|
"error": "parse",
|
|
"detail": f"response is not JSON ({exc.__class__.__name__})",
|
|
}
|
|
|
|
# Accept both the OpenAI shape (`{"data": [{"id": ...}, ...]}`) and the
|
|
# bare-list shape some self-hosted servers return (`[{"id": ...}, ...]`).
|
|
if isinstance(payload, dict) and isinstance(payload.get("data"), list):
|
|
entries = payload["data"]
|
|
elif isinstance(payload, list):
|
|
entries = payload
|
|
else:
|
|
return {
|
|
"ok": False,
|
|
"error": "parse",
|
|
"detail": "response is not in OpenAI /models shape (expected {'data': [...]} or [...])",
|
|
}
|
|
|
|
models = []
|
|
for entry in entries:
|
|
if isinstance(entry, dict) and entry.get("id"):
|
|
mid = str(entry["id"]).strip()
|
|
if mid:
|
|
models.append({"id": mid, "label": mid})
|
|
elif isinstance(entry, str) and entry.strip():
|
|
models.append({"id": entry.strip(), "label": entry.strip()})
|
|
|
|
return {"ok": True, "models": models, "status": status}
|
|
|
|
|
|
def _extract_current_provider(cfg: dict) -> str:
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, dict):
|
|
provider = str(model_cfg.get("provider") or "").strip().lower()
|
|
if provider:
|
|
return provider
|
|
return ""
|
|
|
|
|
|
def _extract_current_model(cfg: dict) -> str:
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, str):
|
|
return model_cfg.strip()
|
|
if isinstance(model_cfg, dict):
|
|
return str(model_cfg.get("default") or "").strip()
|
|
return ""
|
|
|
|
|
|
def _extract_current_base_url(cfg: dict) -> str:
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, dict):
|
|
return _normalize_base_url(str(model_cfg.get("base_url") or ""))
|
|
return ""
|
|
|
|
|
|
def _provider_api_key_present(
|
|
provider: str, cfg: dict, env_values: dict[str, str]
|
|
) -> bool:
|
|
provider = (provider or "").strip().lower()
|
|
if not provider:
|
|
return False
|
|
|
|
env_var = _SUPPORTED_PROVIDER_SETUPS.get(provider, {}).get("env_var")
|
|
if env_var and env_values.get(env_var):
|
|
return True
|
|
|
|
# Legacy env-var aliases (read-only fallback for env vars renamed in past
|
|
# releases — e.g. lmstudio's LM_API_KEY canonical + LMSTUDIO_API_KEY legacy
|
|
# in #1500). Canonical name is what onboarding writes going forward;
|
|
# aliases keep existing users' detection working without forcing an .env
|
|
# rewrite.
|
|
for alias in _SUPPORTED_PROVIDER_SETUPS.get(provider, {}).get("env_var_aliases", []) or []:
|
|
if alias and env_values.get(alias):
|
|
return True
|
|
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
|
return True
|
|
|
|
providers_cfg = cfg.get("providers", {})
|
|
if isinstance(providers_cfg, dict):
|
|
provider_cfg = providers_cfg.get(provider, {})
|
|
if (
|
|
isinstance(provider_cfg, dict)
|
|
and str(provider_cfg.get("api_key") or "").strip()
|
|
):
|
|
return True
|
|
if provider == "custom":
|
|
custom_cfg = providers_cfg.get("custom", {})
|
|
if (
|
|
isinstance(custom_cfg, dict)
|
|
and str(custom_cfg.get("api_key") or "").strip()
|
|
):
|
|
return True
|
|
|
|
# For providers not in _SUPPORTED_PROVIDER_SETUPS (e.g. minimax-cn, deepseek,
|
|
# xai, etc.), ask the hermes_cli auth registry — it knows every provider's env
|
|
# var names and can check os.environ for a valid key.
|
|
# Exclude known OAuth/token-flow providers — those are handled separately by
|
|
# _provider_oauth_authenticated() and should not be short-circuited here.
|
|
_known_oauth = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous", "anthropic"}
|
|
if provider not in _SUPPORTED_PROVIDER_SETUPS and provider not in _known_oauth:
|
|
try:
|
|
from hermes_cli.auth import get_auth_status as _gas
|
|
status = _gas(provider)
|
|
if isinstance(status, dict) and status.get("logged_in"):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
return False
|
|
|
|
|
|
|
|
def _oauth_payload_has_token(payload: dict) -> bool:
|
|
"""Return True if an auth payload contains usable token material."""
|
|
if not isinstance(payload, dict):
|
|
return False
|
|
|
|
token_fields = (
|
|
payload,
|
|
payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {},
|
|
)
|
|
for candidate in token_fields:
|
|
if not isinstance(candidate, dict):
|
|
continue
|
|
if any(
|
|
str(candidate.get(key) or "").strip()
|
|
for key in ("access_token", "refresh_token", "api_key")
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
|
|
def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
|
|
"""Return True if the provider has valid OAuth credentials.
|
|
|
|
Reads the profile-scoped auth.json directly so onboarding respects the
|
|
requested Hermes home. Known OAuth providers may store auth either in the
|
|
legacy providers[provider_id] singleton state or in credential_pool entries
|
|
used by current Hermes runtime auth resolution.
|
|
"""
|
|
provider = (provider or "").strip().lower()
|
|
provider = {"claude": "anthropic", "claude-code": "anthropic"}.get(provider, provider)
|
|
if not provider:
|
|
return False
|
|
|
|
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous", "anthropic"}
|
|
if provider not in _known_oauth_providers:
|
|
return False
|
|
|
|
try:
|
|
import json as _j
|
|
|
|
auth_path = hermes_home / "auth.json"
|
|
if not auth_path.exists():
|
|
return False
|
|
store = _j.loads(auth_path.read_text(encoding="utf-8"))
|
|
|
|
providers_store = store.get("providers")
|
|
if isinstance(providers_store, dict):
|
|
state = providers_store.get(provider)
|
|
if _oauth_payload_has_token(state):
|
|
return True
|
|
|
|
pool_store = store.get("credential_pool")
|
|
if isinstance(pool_store, dict):
|
|
entries = pool_store.get(provider)
|
|
if isinstance(entries, list):
|
|
for entry in entries:
|
|
if _oauth_payload_has_token(entry):
|
|
return True
|
|
if (
|
|
provider == "anthropic"
|
|
and isinstance(entry, dict)
|
|
and entry.get("auth_type") == "oauth"
|
|
and entry.get("source") == "claude_code_linked"
|
|
):
|
|
return True
|
|
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
|
provider = _extract_current_provider(cfg)
|
|
model = _extract_current_model(cfg)
|
|
base_url = _extract_current_base_url(cfg)
|
|
env_values = _load_env_file(_get_active_hermes_home() / ".env")
|
|
|
|
provider_configured = bool(provider and model)
|
|
provider_ready = False
|
|
|
|
if provider_configured:
|
|
meta = _SUPPORTED_PROVIDER_SETUPS.get(provider, {})
|
|
if provider in _SUPPORTED_PROVIDER_SETUPS:
|
|
# key_optional providers (lmstudio, ollama, custom) are ready as
|
|
# soon as the user has saved a provider+model+base_url; an api_key
|
|
# is allowed but not required. The agent runtime substitutes a
|
|
# placeholder for keyless local servers (LMSTUDIO_NOAUTH_PLACEHOLDER
|
|
# for lmstudio, equivalent paths for ollama / custom). See #1499
|
|
# third sub-bug from #1420.
|
|
if meta.get("key_optional"):
|
|
if meta.get("requires_base_url"):
|
|
provider_ready = bool(base_url)
|
|
else:
|
|
provider_ready = True
|
|
else:
|
|
# Standard wizard provider (openrouter, anthropic, openai, gemini,
|
|
# deepseek, zai, …) — needs an api_key. Custom historically also
|
|
# took this branch, but is now key_optional via the meta flag.
|
|
if meta.get("requires_base_url"):
|
|
provider_ready = bool(
|
|
base_url
|
|
and _provider_api_key_present(provider, cfg, env_values)
|
|
)
|
|
else:
|
|
provider_ready = _provider_api_key_present(provider, cfg, env_values)
|
|
if not provider_ready and meta.get("oauth_provider"):
|
|
provider_ready = _provider_oauth_authenticated(
|
|
str(meta.get("oauth_provider")), _get_active_hermes_home()
|
|
)
|
|
else:
|
|
# Unknown provider — may be an OAuth flow (openai-codex, copilot, etc.)
|
|
# OR an API-key provider not in the quick-setup list (minimax-cn, deepseek,
|
|
# xai, etc.). Check both: api key presence first (covers the majority of
|
|
# third-party providers), then OAuth auth.json.
|
|
provider_ready = (
|
|
_provider_api_key_present(provider, cfg, env_values)
|
|
or _provider_oauth_authenticated(provider, _get_active_hermes_home())
|
|
)
|
|
|
|
chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready)
|
|
|
|
if not _HERMES_FOUND or not imports_ok:
|
|
state = "agent_unavailable"
|
|
note = (
|
|
"Hermes is not fully importable from the Web UI yet. Finish bootstrap or fix the "
|
|
"agent install before provider setup will work."
|
|
)
|
|
elif chat_ready:
|
|
state = "ready"
|
|
provider_name = _PROVIDER_DISPLAY.get(
|
|
provider, provider.title() if provider else "Hermes"
|
|
)
|
|
note = f"Hermes is minimally configured and ready to chat via {provider_name}."
|
|
elif provider_configured:
|
|
state = "provider_incomplete"
|
|
if provider == "custom" and not base_url:
|
|
note = (
|
|
"Hermes has a saved provider/model selection but still needs the "
|
|
"base URL and API key required to chat."
|
|
)
|
|
elif provider not in _SUPPORTED_PROVIDER_SETUPS:
|
|
# OAuth / unsupported provider: avoid misleading "API key" wording.
|
|
note = (
|
|
f"Provider '{provider}' is configured but not yet authenticated. "
|
|
"Run 'hermes auth' or 'hermes model' in a terminal to complete "
|
|
"setup, then reload the Web UI."
|
|
)
|
|
else:
|
|
note = (
|
|
"Hermes has a saved provider/model selection but still needs the "
|
|
"API key required to chat."
|
|
)
|
|
else:
|
|
state = "needs_provider"
|
|
note = "Hermes is installed, but you still need to choose a provider and save working credentials."
|
|
|
|
return {
|
|
"provider_configured": provider_configured,
|
|
"provider_ready": provider_ready,
|
|
"chat_ready": chat_ready,
|
|
"setup_state": state,
|
|
"provider_note": note,
|
|
"current_provider": provider or None,
|
|
"current_model": model or None,
|
|
"current_base_url": base_url or None,
|
|
"env_path": str(_get_active_hermes_home() / ".env"),
|
|
}
|
|
|
|
|
|
def _build_setup_catalog(cfg: dict) -> dict:
|
|
current_provider = _extract_current_provider(cfg) or "openrouter"
|
|
current_model = _extract_current_model(cfg)
|
|
current_base_url = _extract_current_base_url(cfg)
|
|
|
|
providers = []
|
|
for provider_id, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
|
providers.append(
|
|
{
|
|
"id": provider_id,
|
|
"label": meta["label"],
|
|
"env_var": meta["env_var"],
|
|
"default_model": meta["default_model"],
|
|
"default_base_url": meta.get("default_base_url") or "",
|
|
"requires_base_url": bool(meta.get("requires_base_url")),
|
|
# #1499 (third sub-bug from #1420) — providers that may run
|
|
# keyless (lmstudio, ollama, custom). Frontend uses this to
|
|
# show a "(optional)" hint and allow Continue without a key.
|
|
"key_optional": bool(meta.get("key_optional")),
|
|
"models": list(meta.get("models", [])),
|
|
"category": meta.get("category", "easy_start"),
|
|
"quick": meta.get("quick", False),
|
|
"oauth_provider": meta.get("oauth_provider") or "",
|
|
"oauth_label": meta.get("oauth_label") or "",
|
|
}
|
|
)
|
|
|
|
# Sort providers by category order, then alphabetically within each category.
|
|
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
|
|
providers.sort(key=lambda p: (cat_order.get(p["category"], 99), p["label"]))
|
|
|
|
# Group providers by category for the frontend.
|
|
categories = []
|
|
for cat in sorted(_PROVIDER_CATEGORIES, key=lambda c: c["order"]):
|
|
categories.append({
|
|
"id": cat["id"],
|
|
"label": cat["label"],
|
|
"providers": [p["id"] for p in providers if p["category"] == cat["id"]],
|
|
})
|
|
|
|
# Flag whether the currently-configured provider is OAuth-based (not in the
|
|
# API-key flow). The frontend uses this to show a confirmation card instead
|
|
# of a key input when the user has already authenticated via 'hermes auth'.
|
|
current_is_oauth = (
|
|
current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(current_provider)
|
|
) or _provider_oauth_authenticated(current_provider, _get_active_hermes_home())
|
|
|
|
return {
|
|
"providers": providers,
|
|
"categories": categories,
|
|
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
|
|
"current_is_oauth": current_is_oauth,
|
|
"current": {
|
|
"provider": current_provider,
|
|
"model": current_model
|
|
or _SUPPORTED_PROVIDER_SETUPS.get(current_provider, {}).get(
|
|
"default_model", ""
|
|
),
|
|
"base_url": current_base_url,
|
|
},
|
|
}
|
|
|
|
|
|
def get_onboarding_status() -> dict:
|
|
settings = load_settings()
|
|
cfg = get_config()
|
|
imports_ok, missing, errors = verify_hermes_imports()
|
|
runtime = _status_from_runtime(cfg, imports_ok)
|
|
workspaces = load_workspaces()
|
|
last_workspace = get_last_workspace()
|
|
available_models = get_available_models()
|
|
|
|
# HERMES_WEBUI_SKIP_ONBOARDING=1 lets hosting providers (e.g. Agent37) ship
|
|
# a pre-configured instance without the wizard blocking the first load.
|
|
# This is an operator-level override and is honoured unconditionally —
|
|
# the operator knows their deployment is configured; we must not second-guess
|
|
# it by requiring chat_ready to also be true.
|
|
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
|
|
skip_requested = skip_env in {"1", "true", "yes"}
|
|
auto_completed = skip_requested # unconditional: operator says skip, we skip
|
|
|
|
# Auto-complete for existing Hermes users: if config.yaml already exists
|
|
# AND the provider is configured (or the system is chat_ready), treat onboarding
|
|
# as done. These users configured Hermes via the CLI before the Web UI existed;
|
|
# they must never be shown the first-run wizard — it would silently overwrite their
|
|
# config. We use provider_configured (not chat_ready) so that users with
|
|
# non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced
|
|
# through the wizard just because their provider doesn't have a detectable API key
|
|
# — the wizard cannot represent their provider and would overwrite their config
|
|
# with whichever wizard-supported provider they accidentally select.
|
|
config_exists = Path(_get_config_path()).exists()
|
|
|
|
# For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek,
|
|
# xai, kimi-k2.6), the wizard can never help — it only knows how to configure
|
|
# openrouter/anthropic/openai/google/custom. If such a user has a configured
|
|
# provider + model in config.yaml, showing the wizard would only confuse them
|
|
# (or worse, let them accidentally overwrite their config with gpt-5.4-mini).
|
|
_current_provider = str(
|
|
(cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict)
|
|
else ""
|
|
).strip().lower()
|
|
_is_non_wizard_provider = bool(
|
|
_current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS
|
|
)
|
|
|
|
config_auto_completed = config_exists and (
|
|
bool(runtime.get("chat_ready"))
|
|
or (_is_non_wizard_provider and bool(runtime.get("provider_configured")))
|
|
)
|
|
|
|
# Persist the flag so it survives future transient import failures (e.g. after
|
|
# a git branch switch in the hermes-agent repo). Without this, a CLI-configured
|
|
# user who never ran the wizard has no onboarding_completed flag — any momentary
|
|
# imports_ok=False during restart makes chat_ready=False, config_auto_completed=False,
|
|
# and the wizard reappears with a broken dropdown that clobbers their config.
|
|
#
|
|
# Best-effort: if save_settings raises (read-only FS, disk full, permission error),
|
|
# log and continue. The `config_auto_completed` branch of `completed=` below still
|
|
# returns True for this request, so the user sees the correct state — only the
|
|
# persistence-across-restart guarantee is degraded. Raising here would turn every
|
|
# /api/onboarding/status call into a 500 until disk was writable, which is worse UX
|
|
# than losing the next-restart protection.
|
|
if config_auto_completed and not settings.get("onboarding_completed"):
|
|
try:
|
|
save_settings({"onboarding_completed": True})
|
|
settings["onboarding_completed"] = True
|
|
except Exception:
|
|
logger.debug("Failed to persist onboarding_completed", exc_info=True)
|
|
|
|
return {
|
|
"completed": bool(settings.get("onboarding_completed")) or auto_completed or config_auto_completed,
|
|
"settings": {
|
|
"default_model": settings.get("default_model") or DEFAULT_MODEL,
|
|
"default_workspace": settings.get("default_workspace")
|
|
or str(DEFAULT_WORKSPACE),
|
|
"password_enabled": is_auth_enabled(),
|
|
"bot_name": settings.get("bot_name") or "Hermes",
|
|
},
|
|
"system": {
|
|
"hermes_found": bool(_HERMES_FOUND),
|
|
"imports_ok": bool(imports_ok),
|
|
"missing_modules": missing,
|
|
"import_errors": errors,
|
|
"config_path": str(_get_config_path()),
|
|
"config_exists": Path(_get_config_path()).exists(),
|
|
**runtime,
|
|
},
|
|
"setup": _build_setup_catalog(cfg),
|
|
"workspaces": {
|
|
"items": workspaces,
|
|
"last": last_workspace,
|
|
},
|
|
"models": available_models,
|
|
}
|
|
|
|
|
|
def apply_onboarding_setup(body: dict) -> dict:
|
|
# Hard guard: if the operator set SKIP_ONBOARDING, the wizard should never
|
|
# have appeared. Even if the frontend somehow calls this endpoint anyway
|
|
# (e.g. a stale JS bundle or a curious user), we must not overwrite the
|
|
# operator's config.yaml or .env files. Just mark onboarding complete and
|
|
# return the current status — no file writes.
|
|
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
|
|
if skip_env in {"1", "true", "yes"}:
|
|
save_settings({"onboarding_completed": True})
|
|
return get_onboarding_status()
|
|
|
|
provider = str(body.get("provider") or "").strip().lower()
|
|
model = str(body.get("model") or "").strip()
|
|
api_key = str(body.get("api_key") or "").strip()
|
|
base_url = _normalize_base_url(str(body.get("base_url") or ""))
|
|
|
|
if provider not in _SUPPORTED_PROVIDER_SETUPS:
|
|
# Unsupported providers (openai-codex, copilot, nous, etc.) are already
|
|
# configured via the CLI. Just mark onboarding as complete and let the
|
|
# user through — the agent is already set up, no further setup needed.
|
|
save_settings({"onboarding_completed": True})
|
|
return get_onboarding_status()
|
|
if not model:
|
|
raise ValueError("model is required")
|
|
|
|
provider_meta = _SUPPORTED_PROVIDER_SETUPS[provider]
|
|
if provider_meta.get("requires_base_url"):
|
|
if not base_url:
|
|
raise ValueError("base_url is required for custom endpoints")
|
|
parsed = urlparse(base_url)
|
|
if parsed.scheme not in {"http", "https"}:
|
|
raise ValueError("base_url must start with http:// or https://")
|
|
|
|
config_path = _get_config_path()
|
|
# Guard: if config.yaml already exists and the caller did not explicitly
|
|
# acknowledge the overwrite, refuse to proceed. The frontend must pass
|
|
# confirm_overwrite=True after showing the user a confirmation step.
|
|
if Path(config_path).exists() and not body.get("confirm_overwrite"):
|
|
return {
|
|
"error": "config_exists",
|
|
"message": (
|
|
"Hermes is already configured (config.yaml exists). "
|
|
"Pass confirm_overwrite=true to overwrite it."
|
|
),
|
|
"requires_confirm": True,
|
|
}
|
|
|
|
cfg = _load_yaml_config(config_path)
|
|
env_path = _get_active_hermes_home() / ".env"
|
|
env_values = _load_env_file(env_path)
|
|
|
|
if not api_key and not _provider_api_key_present(provider, cfg, env_values):
|
|
# Providers that may run keyless (lmstudio, ollama, custom — gated by
|
|
# `key_optional` in _SUPPORTED_PROVIDER_SETUPS) are allowed to onboard
|
|
# with no api_key. OAuth-capable wizard providers (currently Anthropic
|
|
# via Claude Code) are also allowed once their server-side OAuth/link
|
|
# marker is present.
|
|
oauth_ready = bool(provider_meta.get("oauth_provider")) and _provider_oauth_authenticated(
|
|
str(provider_meta.get("oauth_provider")), _get_active_hermes_home()
|
|
)
|
|
if not provider_meta.get("key_optional") and not oauth_ready:
|
|
raise ValueError(f"{provider_meta['env_var']} is required")
|
|
|
|
model_cfg = cfg.get("model", {})
|
|
if not isinstance(model_cfg, dict):
|
|
model_cfg = {}
|
|
|
|
model_cfg["provider"] = provider
|
|
model_cfg["default"] = _normalize_model_for_provider(provider, model)
|
|
|
|
if provider_meta.get("requires_base_url"):
|
|
model_cfg["base_url"] = base_url
|
|
elif provider_meta.get("default_base_url"):
|
|
model_cfg["base_url"] = provider_meta["default_base_url"]
|
|
else:
|
|
model_cfg.pop("base_url", None)
|
|
|
|
cfg["model"] = model_cfg
|
|
_save_yaml_config(config_path, cfg)
|
|
|
|
if api_key:
|
|
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
|
|
|
|
# Reload the hermes_cli provider/config cache so the next streaming call
|
|
# picks up the new key without requiring a server restart.
|
|
try:
|
|
from api.profiles import _reload_dotenv
|
|
_reload_dotenv(_get_active_hermes_home())
|
|
except Exception:
|
|
logger.debug("Failed to reload dotenv")
|
|
|
|
# Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the
|
|
# value survives even if _reload_dotenv cleared it (e.g. when _write_env_file
|
|
# wrote to disk but the profile isolation tracking hasn't seen it yet).
|
|
if api_key:
|
|
os.environ[provider_meta["env_var"]] = api_key
|
|
|
|
try:
|
|
# hermes_cli may cache config at import time; ask it to reload if possible.
|
|
from hermes_cli.config import reload as _cli_reload
|
|
_cli_reload()
|
|
except Exception:
|
|
logger.debug("Failed to reload hermes_cli config")
|
|
|
|
reload_config()
|
|
return get_onboarding_status()
|
|
|
|
|
|
def complete_onboarding() -> dict:
|
|
save_settings({"onboarding_completed": True})
|
|
return get_onboarding_status()
|