"""Config, env var and provider custom-endpoint dashboard routes. Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on ``web_server`` stay there and are imported lazily at call time (cycle-safe). """ import logging import re import asyncio import time import urllib.parse from fastapi import APIRouter from hermes_cli.web_routers._common import http_failure from hermes_cli.web_deps import late from fastapi import HTTPException, Request from hermes_cli.config import DEFAULT_CONFIG, OPTIONAL_ENV_VARS, read_raw_config, custom_endpoint_key_env, coerce_provider_id, find_provider_entry, redact_key, _deep_merge from hermes_cli.web_models import ConfigUpdate, EnvVarUpdate, EnvVarDelete, EnvVarReveal, CustomEndpointUpdate from typing import Any, Dict, List, Optional, Tuple _log = logging.getLogger("hermes_cli.web_server") config_router = APIRouter() router = APIRouter() # web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative. _apply_main_model_assignment = late("_apply_main_model_assignment") _approval_mode_of = late("_approval_mode_of") _broadcast_gateway_session_info = late("_broadcast_gateway_session_info") _channel_managed_env_keys = late("_channel_managed_env_keys") _config_profile_scope = late("_config_profile_scope") _denormalize_config_from_web = late("_denormalize_config_from_web") _is_other_profile = late("_is_other_profile") _normalize_config_for_web = late("_normalize_config_for_web") _parse_model_ids = late("_parse_model_ids") _profile_scope = late("_profile_scope") _require_token = late("_require_token") _schema_with_dynamic_provider_options = late("_schema_with_dynamic_provider_options") load_config = late("load_config") load_env = late("load_env") remove_env_value = late("remove_env_value") save_config = late("save_config") save_env_value = late("save_env_value") # Simple rate limiter for the reveal endpoint _reveal_timestamps: List[float] = [] _REVEAL_MAX_PER_WINDOW = 5 _REVEAL_WINDOW_SECONDS = 30 # Display order for tabs — unlisted categories sort alphabetically after these. _CATEGORY_ORDER = [ "general", "agent", "terminal", "display", "delegation", "memory", "compression", "security", "browser", "voice", "tts", "stt", "logging", "discord", "auxiliary", ] @config_router.get("/api/config") async def get_config(profile: Optional[str] = None): # _profile_scope blocks on the process-wide _SKILLS_PROFILE_LOCK and # load_config() reads from disk; on the event loop a slow lock-holder # froze the whole gateway for >1s (observed via the loop watchdog). # asyncio.to_thread copies the contextvar context, so the profile # override stays scoped to the worker thread. def _run(): with _profile_scope(profile): return _normalize_config_for_web(load_config()) config = await asyncio.to_thread(_run) # Strip internal keys that the frontend shouldn't see or send back return {k: v for k, v in config.items() if not k.startswith("_")} @config_router.get("/api/config/defaults") async def get_defaults(): return DEFAULT_CONFIG @config_router.get("/api/config/schema") async def get_schema(profile: Optional[str] = None): # Discovery-driven provider options (voice command providers + memory # provider plugins) are merged per-request so providers added after server # start still show up, scoped to the requested profile's config. with _config_profile_scope(profile): fields = _schema_with_dynamic_provider_options() return {"fields": fields, "category_order": _CATEGORY_ORDER} @config_router.get("/api/egress/status") async def get_egress_status(): """Dashboard/Desktop-readable egress proxy status and remediation text.""" from hermes_cli.proxy_cli import format_status_text return {"text": format_status_text()} @router.put("/api/config") async def update_config(body: ConfigUpdate, profile: Optional[str] = None): from hermes_cli.web_server import _CONFIG_MUTATION_LOCK def _run(): approvals_mode_changed = False with _profile_scope(body.profile or profile): # The dashboard form is schema-driven (see CONFIG_SCHEMA). Any root # key absent from the schema — most visibly ``custom_providers``, but # also ``agent.personalities``, ``terminal.lifetime_seconds``, etc. — # is not sent in the PUT body. A full-replace save would silently # drop those keys. Deep-merge incoming over what's on disk so the # frontend can only overwrite what it explicitly sends. with _CONFIG_MUTATION_LOCK: existing = read_raw_config() incoming = _denormalize_config_from_web(body.config) merged = _deep_merge(existing, incoming) # Compare normalized approvals.mode across the in-memory # documents, not config blocks and not cache re-reads: the # settings page PUTs the defaulted GET record while disk # holds sparse YAML, so a block compare is always-unequal # (every autosave would broadcast), and reloading after the # save can serve the pre-save cache on an (mtime_ns, size) # key collision. Only approvals.mode feeds session.info, so # it is the honest trigger. approvals_mode_changed = _approval_mode_of(merged) != _approval_mode_of(existing) save_config(merged) # REST saves bypass the config.set RPC (which re-emits itself), so # refresh live sessions' cached approval/YOLO indicators after a mode # change. Own-profile saves only: a profile-scoped save targets a # different HERMES_HOME than this process's gateway sessions. if approvals_mode_changed and not _is_other_profile(body.profile or profile): _broadcast_gateway_session_info() return {"ok": True} with http_failure("PUT /api/config failed", 500, detail="Internal server error"): return await asyncio.to_thread(_run) def _catalog_provider_env_metadata() -> dict: """Map provider env vars → desktop card metadata, derived from the catalog. Returns ``{env_var: {provider, provider_label, description, url, is_password, advanced}}`` for every API-key provider in the unified ``provider_catalog()`` (i.e. the ``hermes model`` universe). This is what lets the desktop Keys tab render a card for a provider even when its env var was never hand-added to ``OPTIONAL_ENV_VARS`` — closing the drift where CLI-configurable providers (openai-api, kilocode, novita, tencent-tokenhub, copilot, …) were missing from the GUI. Hand ``OPTIONAL_ENV_VARS`` prose is layered ON TOP of this in the endpoint; this only supplies membership + grouping + sensible fallbacks. """ try: from hermes_cli.provider_catalog import provider_catalog except Exception: return {} # Env vars already declared with a NON-provider category (e.g. the shared # GITHUB_TOKEN, which is a Skills-Hub "tool" credential) must not be # promoted into a provider card. Copilot lists GITHUB_TOKEN among its auth # aliases, but its provider card uses the provider-owned COPILOT_GITHUB_TOKEN. try: from hermes_cli.config import OPTIONAL_ENV_VARS as _OPT except Exception: _OPT = {} _non_provider_keys = { k for k, v in _OPT.items() if (v or {}).get("category") and (v or {}).get("category") != "provider" } meta: dict = {} for d in provider_catalog(): if d.tab != "keys": continue # API-key vars: the first is the primary (password) field; any aliases # are kept as additional password fields so users can clear them too. for env_var in d.api_key_env_vars: if env_var in _non_provider_keys: continue # don't hijack a shared tool/messaging credential meta.setdefault( env_var, { "provider": d.slug, "provider_label": d.label, "description": d.description, "url": d.signup_url or None, "is_password": True, "advanced": False, "category": "provider", }, ) # Base-URL override is an advanced, non-secret field for the same card. if d.base_url_env_var: meta.setdefault( d.base_url_env_var, { "provider": d.slug, "provider_label": d.label, "description": f"{d.label} base URL override", "url": None, "is_password": False, "advanced": True, "category": "provider", }, ) # AWS-SDK providers (Bedrock) authenticate via the AWS credential chain # rather than a pasted API key, so they have no api_key_env_vars. Tag # their AWS_* settings to the provider card so they still appear on the # Keys tab (otherwise Bedrock — a `hermes model` provider — would be # invisible in the desktop app). if d.auth_type == "aws_sdk": for aws_var in ("AWS_REGION", "AWS_PROFILE"): existing = meta.get(aws_var, {}) meta[aws_var] = { "provider": d.slug, "provider_label": d.label, "description": existing.get("description") or f"{d.label} ({aws_var})", "url": existing.get("url"), "is_password": False, "advanced": existing.get("advanced", True), "category": "provider", } # Vertex AI authenticates via OAuth2 (service-account JSON or ADC), not a # pasted API key, so it also has no api_key_env_vars. Tag its credential # env var to the provider card so it appears on the Keys tab (otherwise # Vertex — a `hermes model` provider — would be invisible in the desktop # app). The value is a filesystem path, not a secret string, so it is # not a password field. if d.auth_type == "vertex": existing = meta.get("VERTEX_CREDENTIALS_PATH", {}) meta["VERTEX_CREDENTIALS_PATH"] = { "provider": d.slug, "provider_label": d.label, "description": existing.get("description") or f"{d.label} — service account JSON path (or use ADC)", "url": existing.get("url"), "is_password": False, "advanced": existing.get("advanced", True), "category": "provider", } return meta @router.get("/api/env") async def get_env_vars(profile: Optional[str] = None): # _profile_scope takes _SKILLS_PROFILE_LOCK and load_env()/catalog # discovery read from disk — keep the whole build off the event loop. return await asyncio.to_thread(_get_env_vars_sync, profile) def _get_env_vars_sync(profile: Optional[str] = None): with _profile_scope(profile): env_on_disk = load_env() channel_keys = _channel_managed_env_keys() catalog_meta = _catalog_provider_env_metadata() def _row(var_name: str, info: dict, *, custom: bool = False) -> dict: value = env_on_disk.get(var_name) cat_meta = catalog_meta.get(var_name) or {} # Hand OPTIONAL_ENV_VARS prose wins where present; the catalog fills any # gaps (description/url) and always supplies provider grouping hints. return { "is_set": bool(value), "redacted_value": redact_key(value) if value else None, "description": info.get("description") or cat_meta.get("description", ""), "url": info.get("url") if info.get("url") is not None else cat_meta.get("url"), "category": info.get("category") or cat_meta.get("category", ""), "is_password": info.get("password", cat_meta.get("is_password", False)), "tools": info.get("tools", []), "advanced": info.get("advanced", cat_meta.get("advanced", False)), # True when this var is a messaging-platform credential owned by a # Channels page card. The Keys/Env page uses this to hide it and # avoid duplicating the (richer) Channels configuration UI. "channel_managed": var_name in channel_keys, # Provider grouping hints derived from the unified provider catalog # so the desktop Keys tab groups by the SAME provider identity the # CLI `hermes model` picker uses (not desktop-only prefix guesses). "provider": cat_meta.get("provider", ""), "provider_label": cat_meta.get("provider_label", ""), # True when this key exists in the user's .env but is NOT in any # catalog (OPTIONAL_ENV_VARS or the provider catalog) — an # arbitrary/custom env var the user added directly. Surfaced so the # Keys page can list (and let the user manage) them instead of # hiding everything it doesn't recognise. "custom": custom, } result = {} for var_name, info in OPTIONAL_ENV_VARS.items(): result[var_name] = _row(var_name, info) # Synthesize rows for catalog provider env vars that have no hand entry in # OPTIONAL_ENV_VARS — these are the providers that were CLI-configurable but # invisible in the desktop app until now. for var_name in catalog_meta: if var_name not in result: result[var_name] = _row(var_name, {}) # Surface arbitrary/custom keys the user set in .env that aren't in any # catalog. These are always "set" (they're on disk). Treated as secrets by # default (is_password=True → redacted, reveal-gated) since an unrecognised # key could hold anything. Channel-managed credentials are excluded — those # belong to the Channels page. This makes the "add a custom key" surface # round-trip: a key added there reappears here under its own section. for var_name in env_on_disk: if var_name in result or var_name in channel_keys: continue row = _row(var_name, {}, custom=True) row["category"] = "custom" row["is_password"] = True result[var_name] = row return result @router.put("/api/env") async def set_env_var(body: EnvVarUpdate, profile: Optional[str] = None): def _run(): with _profile_scope(body.profile or profile): # Unified credential lifecycle: writes .env AND reconciles any # config.yaml mirror still holding the previous value of this var # (model.api_key / auxiliary.*.api_key / custom_providers[*]), # so a rotation can't leave a stale higher-precedence copy that # keeps authenticating with the old key (#62269). from hermes_cli.credential_lifecycle import save_provider_env_credential return save_provider_env_credential(body.key, body.value) try: return await asyncio.to_thread(_run) except ValueError as exc: # save_env_value raises ValueError for invalid names and for keys # on the denylist (LD_PRELOAD, PATH, PYTHONPATH, …). Surface the # message to the SPA so the user understands why the write was # refused instead of seeing an opaque 500. raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception: _log.exception("PUT /api/env failed") raise HTTPException(status_code=500, detail="Internal server error") # Live credential probes keyed by env var. Each entry is (method, url, auth) # where auth is "bearer" (Authorization header) or "query" (?key=). A cheap # read-only models/key call that 401s on a bad token — enough to catch a # mistyped key before it's persisted. Providers absent from this map (or local # endpoints) are not network-validated; the client treats those as "unknown". _CREDENTIAL_PROBES: dict[str, tuple[str, str]] = { "OPENROUTER_API_KEY": ("https://openrouter.ai/api/v1/key", "bearer"), "OPENAI_API_KEY": ("https://api.openai.com/v1/models", "bearer"), "XAI_API_KEY": ("https://api.x.ai/v1/models", "bearer"), "GEMINI_API_KEY": ("https://generativelanguage.googleapis.com/v1beta/models", "query"), } def _custom_endpoint_id(raw: str, fallback: str = "custom") -> str: slug = re.sub(r"[^A-Za-z0-9_-]+", "-", coerce_provider_id(raw)).strip("-_").lower() return slug or fallback def _models_from_custom_endpoint_entry(entry: Dict[str, Any]) -> List[str]: models: List[str] = [] raw_models = entry.get("models") if isinstance(raw_models, dict): models.extend(str(model).strip() for model in raw_models.keys()) elif isinstance(raw_models, list): models.extend(str(model).strip() for model in raw_models) default_model = str(entry.get("model") or entry.get("default_model") or "").strip() if default_model: models.insert(0, default_model) seen: set[str] = set() return [model for model in models if model and not (model in seen or seen.add(model))] def _api_key_display(entry: Dict[str, Any]) -> Tuple[bool, Optional[str]]: """Return ``(has_api_key, preview)`` for a provider or model config block. Keys live in ``.env`` behind ``key_env``; only entries written before #69449 still carry a plaintext ``api_key``. Checking both keeps the panel honest either way — reading only ``api_key`` reported "no API key" for every endpoint whose key had been moved to ``.env``. """ plaintext = str(entry.get("api_key") or "").strip() if plaintext: return True, redact_key(plaintext) key_env = str(entry.get("key_env") or "").strip() if key_env: return True, f"${{{key_env}}}" return False, None def _config_api_key_is_env_ref(endpoint_id: str) -> bool: """True when this endpoint's on-disk ``api_key`` is a ``${VAR}`` template. ``load_config()`` expands env refs, so a hand-written ``api_key: ${MY_KEY}`` is indistinguishable from a literal secret by the time it reaches us. Such an entry is already keeping its secret out of config.yaml, so migrating it would only copy that secret into a second env var the user didn't ask for. """ _stored, entry = find_provider_entry(read_raw_config().get("providers"), endpoint_id) raw_key = entry.get("api_key") if isinstance(entry, dict) else None return bool(isinstance(raw_key, str) and re.search(r"\$\{[^}]+\}", raw_key)) def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {} current_provider = str(model_cfg.get("provider", "") or "") current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "") current_base_url = str(model_cfg.get("base_url", "") or "") endpoints: List[Dict[str, Any]] = [] providers = cfg.get("providers") if isinstance(providers, dict): for provider_id, raw_entry in providers.items(): if not isinstance(raw_entry, dict): continue base_url = str(raw_entry.get("base_url") or raw_entry.get("url") or raw_entry.get("api") or "").strip() if not base_url: continue endpoint_id = str(provider_id) models = _models_from_custom_endpoint_entry(raw_entry) endpoint_model = str(raw_entry.get("model") or raw_entry.get("default_model") or (models[0] if models else "")) has_api_key, api_key_preview = _api_key_display(raw_entry) endpoints.append({ "id": endpoint_id, "name": str(raw_entry.get("name") or endpoint_id), "base_url": base_url, "model": endpoint_model, "models": models, "context_length": raw_entry.get("context_length"), "discover_models": bool(raw_entry.get("discover_models", True)), "has_api_key": has_api_key, "api_key_preview": api_key_preview, "is_current": endpoint_id == current_provider, "source": "providers", }) if current_provider.lower() == "custom" and current_base_url and not any(e["id"] == "custom" for e in endpoints): has_api_key, api_key_preview = _api_key_display(model_cfg) endpoints.insert(0, { "id": "custom", "name": "Custom", "base_url": current_base_url, "model": current_model, "models": [current_model] if current_model else [], "context_length": model_cfg.get("context_length"), "discover_models": True, "has_api_key": has_api_key, "api_key_preview": api_key_preview, "is_current": True, "source": "direct-config", }) return { "endpoints": endpoints, "current": { "provider": current_provider, "model": current_model, "base_url": current_base_url, }, } def _detach_main_model_from_provider(cfg: Dict[str, Any], provider_key: str) -> None: """Drop the main-slot mirror of a provider that no longer exists. ``activate_custom_endpoint`` copies the endpoint's ``base_url`` and ``api_key`` onto ``model``. That mirror outranks the environment at client construction (#62269), so deleting the endpoint without clearing it leaves the agent still authenticating to the deleted host with the deleted key — and leaves that key sitting in config.yaml after the operator believes the dashboard removed it. Only touches ``model`` when it actually names the deleted provider, so an endpoint deleted while a *different* provider is active is left alone. """ model_cfg = cfg.get("model") if not isinstance(model_cfg, dict): return if str(model_cfg.get("provider") or "").strip().lower() != provider_key: return for field in ("provider", "base_url", "api_key", "key_env"): model_cfg.pop(field, None) cfg["model"] = model_cfg def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> Tuple[str, Dict[str, Any]]: endpoint_id = _custom_endpoint_id(body.id or body.name) name = (body.name or "").strip() base_url = (body.base_url or "").strip().rstrip("/") model = (body.model or "").strip() if not name: raise HTTPException(status_code=400, detail="name required") if not base_url: raise HTTPException(status_code=400, detail="base_url required") parsed = urllib.parse.urlparse(base_url) if not parsed.scheme or not parsed.netloc: raise HTTPException(status_code=400, detail="base_url must include scheme and host") if not model: raise HTTPException(status_code=400, detail="model required") providers = cfg.get("providers") if not isinstance(providers, dict): providers = {} stored_key, existing = find_provider_entry(providers, endpoint_id) if existing is None: existing = {} # Merge onto the existing entry rather than replacing it. A providers. # block is not owned by this panel: it can carry hand-written keys the # dashboard has no field for — ``api_mode``, ``key_env``/``api_key_env``, # ``extra_headers`` (which may themselves carry credentials), # ``request_overrides`` — and rebuilding from scratch silently dropped every # one of them on an unrelated edit, leaving a provider that no longer # authenticates or speaks the right protocol. entry: Dict[str, Any] = dict(existing) entry.update({ "name": name, "base_url": base_url, "model": model, "discover_models": bool(body.discover_models), }) # Same for the model map: merge rather than replace, so existing models # keep their context lengths. ``body.models`` is the catalogue the panel's # Test button already discovered — without it only the one hand-typed # model survived Save, and every picker showed a single-entry list for a # provider serving dozens (#69988). A payload with no ``models`` (older # UI) still just ensures the named default is present. existing_models = entry.get("models") models_map: Dict[str, Any] = dict(existing_models) if isinstance(existing_models, dict) else {} for candidate in (*(body.models or ()), model): model_id = str(candidate).strip() if not model_id: continue current = models_map.get(model_id) models_map[model_id] = dict(current) if isinstance(current, dict) else {} entry["models"] = models_map if body.context_length and body.context_length > 0: entry["context_length"] = int(body.context_length) entry["models"][model]["context_length"] = int(body.context_length) # API keys never belong in config.yaml (#69449). Write to .env and # reference it via ``key_env`` — the same indirection built-in providers # use and that runtime_provider.py already resolves at load time. env_var = custom_endpoint_key_env(endpoint_id) submitted_key = body.api_key.strip() if body.api_key is not None else None if submitted_key: save_env_value(env_var, submitted_key) entry["key_env"] = env_var entry.pop("api_key", None) elif submitted_key is not None: # Blank field means "clear the key", not "leave it alone". remove_env_value(env_var) entry.pop("key_env", None) entry.pop("api_key", None) elif str(entry.get("api_key") or "").strip() and not _config_api_key_is_env_ref(endpoint_id): # No new key submitted, but this entry still carries one an earlier # release wrote in plaintext. Migrate it on the next save so endpoints # configured before the fix get cleaned up too, without the user # having to re-enter the key. save_env_value(env_var, entry["api_key"].strip()) entry["key_env"] = env_var entry.pop("api_key", None) if stored_key is not None and stored_key != endpoint_id: providers.pop(stored_key, None) providers[endpoint_id] = entry cfg["providers"] = providers if body.make_default: cfg["model"] = _apply_main_model_assignment( cfg.get("model", {}), endpoint_id, model, base_url ) if entry.get("key_env") and isinstance(cfg["model"], dict): cfg["model"]["key_env"] = entry["key_env"] cfg["model"].pop("api_key", None) return endpoint_id, entry @router.get("/api/providers/custom-endpoints") def list_custom_endpoints(profile: Optional[str] = None): """Return configured OpenAI-compatible custom endpoints for Desktop. Scoped to the requested profile's config.yaml (issue: custom providers only landing in the default profile): the desktop settings UI targets the active profile, so read/write must resolve that profile's home rather than the process-level HERMES_HOME. Mirrors ``/api/config``'s profile scoping. """ with http_failure("GET /api/providers/custom-endpoints failed", 500, detail="Failed to list custom endpoints"): with _config_profile_scope(profile): return _custom_endpoint_response(load_config()) @router.post("/api/providers/custom-endpoints") def upsert_custom_endpoint(body: CustomEndpointUpdate, profile: Optional[str] = None): """Create or update a v12+ ``providers`` custom endpoint entry.""" with http_failure("POST /api/providers/custom-endpoints failed", 500, detail="Failed to save custom endpoint"): with _config_profile_scope(profile): cfg = load_config() endpoint_id, _entry = _write_custom_endpoint(cfg, body) save_config(cfg) response = _custom_endpoint_response(cfg) response["ok"] = True response["id"] = endpoint_id return response @router.post("/api/providers/custom-endpoints/{endpoint_id}/activate") def activate_custom_endpoint(endpoint_id: str, profile: Optional[str] = None): """Set a configured custom endpoint as the default model provider.""" try: with _config_profile_scope(profile): cfg = load_config() provider_key = _custom_endpoint_id(endpoint_id) _stored, entry = find_provider_entry(cfg.get("providers"), provider_key) if entry is None: raise HTTPException(status_code=404, detail="custom endpoint not found") models = _models_from_custom_endpoint_entry(entry) model = str(entry.get("model") or (models[0] if models else "")).strip() base_url = str(entry.get("base_url") or "").strip() if not model or not base_url: raise HTTPException(status_code=400, detail="custom endpoint is incomplete") model_cfg = _apply_main_model_assignment(cfg.get("model", {}), provider_key, model, base_url) if entry.get("key_env"): model_cfg["key_env"] = entry["key_env"] model_cfg.pop("api_key", None) elif entry.get("api_key"): # Same #88990 shape as /api/model/set: `cfg` is env-expanded, # so a raw `${VAR}` api_key would land as plaintext. Copy the # raw template when that's what's on disk. _raw_entry = None try: _stored_raw, _raw_entry = find_provider_entry( read_raw_config().get("providers"), provider_key ) except Exception: _raw_entry = None _raw_key = ( str(_raw_entry.get("api_key") or "").strip() if isinstance(_raw_entry, dict) else "" ) if _raw_key.startswith("${") and _raw_key.endswith("}"): model_cfg["api_key"] = _raw_key else: model_cfg["api_key"] = entry["api_key"] cfg["model"] = model_cfg save_config(cfg) return {"ok": True, "provider": provider_key, "model": model} except HTTPException: raise except Exception: _log.exception("POST /api/providers/custom-endpoints/%s/activate failed", endpoint_id) raise HTTPException(status_code=500, detail="Failed to activate custom endpoint") @router.delete("/api/providers/custom-endpoints/{endpoint_id}") def delete_custom_endpoint(endpoint_id: str, profile: Optional[str] = None): """Remove a configured custom endpoint from ``providers``.""" try: with _config_profile_scope(profile): cfg = load_config() provider_key = _custom_endpoint_id(endpoint_id) providers = cfg.get("providers") stored_key, entry = find_provider_entry(providers, provider_key) if entry is None or not isinstance(providers, dict): raise HTTPException(status_code=404, detail="custom endpoint not found") providers.pop(stored_key, None) cfg["providers"] = providers _detach_main_model_from_provider(cfg, provider_key) remove_env_value(custom_endpoint_key_env(provider_key)) save_config(cfg) response = _custom_endpoint_response(cfg) response["ok"] = True return response except HTTPException: raise except Exception: _log.exception("DELETE /api/providers/custom-endpoints/%s failed", endpoint_id) raise HTTPException(status_code=500, detail="Failed to delete custom endpoint") @router.post("/api/providers/custom-endpoints/validate") async def validate_custom_endpoint(body: CustomEndpointUpdate): """Probe a custom endpoint by calling its OpenAI-compatible /models URL.""" import httpx base_url = (body.base_url or "").strip().rstrip("/") if not base_url: return {"ok": False, "reachable": True, "message": "Enter an endpoint URL first.", "models": []} url = base_url + "/models" headers = {"Accept": "application/json"} if body.api_key and body.api_key.strip(): headers["Authorization"] = f"Bearer {body.api_key.strip()}" try: async with httpx.AsyncClient(timeout=httpx.Timeout(8.0)) as client: resp = await client.get(url, headers=headers) except Exception: return {"ok": False, "reachable": False, "message": f"Could not reach {url}.", "models": []} if resp.status_code in (401, 403): return {"ok": False, "reachable": True, "message": "The endpoint rejected the API key.", "models": []} if not resp.is_success: return {"ok": False, "reachable": True, "message": f"Endpoint returned HTTP {resp.status_code}.", "models": []} return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)} @router.post("/api/providers/validate") async def validate_provider_credential(body: EnvVarUpdate, request: Request): """Live-probe a provider credential before it's saved. Returns {ok, reachable, message}. ok=True means the provider accepted the key; ok=False + reachable=True means the key is bad (caller should block); reachable=False means the network probe couldn't run (caller may save with a warning rather than hard-blocking offline users). """ _require_token(request) import httpx key = (body.key or "").strip() value = (body.value or "").strip() if not value: return {"ok": False, "reachable": True, "message": "Enter a value first."} # Local / custom endpoint: validate connectivity, not auth — any HTTP # response (even 401) proves the endpoint is up. Also surface the model # ids the endpoint advertises (OpenAI ``/v1/models`` shape) so the GUI can # auto-pick a default without asking the user to type a model name. if key == "OPENAI_BASE_URL": url = value.rstrip("/") + "/models" # Send the optional API key so endpoints that require auth on # ``/v1/models`` (many hosted OpenAI-compatible servers) still enumerate # their models instead of returning an empty list behind a 401. api_key = (body.api_key or "").strip() headers = {"Authorization": f"Bearer {api_key}"} if api_key else None try: async with httpx.AsyncClient(timeout=httpx.Timeout(8.0)) as client: resp = await client.get(url, headers=headers) return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)} except Exception: return {"ok": False, "reachable": False, "message": f"Could not reach {url}."} probe = _CREDENTIAL_PROBES.get(key) if not probe: # No probe for this provider — can't validate, don't block. return {"ok": True, "reachable": False, "message": ""} url, auth = probe headers = {"Accept": "application/json"} params = {} if auth == "bearer": headers["Authorization"] = f"Bearer {value}" else: params["key"] = value try: async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: resp = await client.get(url, headers=headers, params=params) except Exception: return {"ok": False, "reachable": False, "message": "Could not reach the provider to verify the key."} if resp.status_code in (401, 403): return {"ok": False, "reachable": True, "message": "That API key was rejected. Double-check it and try again."} if resp.status_code == 429 or resp.is_success: # 429 = key is valid but rate-limited; success = valid. return {"ok": True, "reachable": True, "message": ""} return {"ok": False, "reachable": True, "message": f"Provider returned HTTP {resp.status_code} for this key."} @router.delete("/api/env") async def remove_env_var(body: EnvVarDelete, profile: Optional[str] = None): def _run(): with _profile_scope(body.profile or profile): # Unified credential lifecycle: clears the .env entry AND every # mirror of the credential — env-seeded credential_pool entries in # auth.json (stale ones kept providers alive in the model picker, # #51071/#59761), the affected providers' model-cache rows, and # value-matched config.yaml api_key mirrors. OAuth/device-code/ # manual pool entries for the same provider are preserved. from hermes_cli.credential_lifecycle import remove_provider_env_credential return remove_provider_env_credential(body.key) try: result = await asyncio.to_thread(_run) if not result.get("found"): raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") return result except HTTPException: raise except ValueError as exc: # remove_env_value raises ValueError for invalid key names. Surface # the message to the SPA so the user understands why the delete was # refused instead of seeing an opaque 500. Mirrors PUT /api/env. raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception: _log.exception("DELETE /api/env failed") raise HTTPException(status_code=500, detail="Internal server error") @router.post("/api/env/reveal") async def reveal_env_var( body: EnvVarReveal, request: Request, profile: Optional[str] = None ): """Return the real (unredacted) value of a single env var. Protected by: - Ephemeral session token (generated per server start, injected into SPA) - Rate limiting (max 5 reveals per 30s window) - Audit logging """ # --- Token check --- _require_token(request) # --- Rate limit --- now = time.time() cutoff = now - _REVEAL_WINDOW_SECONDS _reveal_timestamps[:] = [t for t in _reveal_timestamps if t > cutoff] if len(_reveal_timestamps) >= _REVEAL_MAX_PER_WINDOW: raise HTTPException(status_code=429, detail="Too many reveal requests. Try again shortly.") _reveal_timestamps.append(now) # --- Reveal --- def _run(): with _profile_scope(body.profile or profile): return load_env() env_on_disk = await asyncio.to_thread(_run) value = env_on_disk.get(body.key) if value is None: raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") _log.info("env/reveal: %s", body.key) return {"key": body.key, "value": value}