refactor(hermes_cli/web_routers): config_env — provider-card builder, env-write error ctx, http_failure on endpoint routes

This commit is contained in:
Teknium
2026-09-02 21:11:44 -07:00
parent d68f930182
commit 80de85c3e6
+157 -232
View File
@@ -1,9 +1,10 @@
"""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).
``web_server`` stay there and are late-bound (cycle-safe).
"""
import contextlib
import logging
import re
import asyncio
@@ -11,7 +12,7 @@ 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 hermes_cli.web_deps import LateState, 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
@@ -39,18 +40,14 @@ load_env = late("load_env")
remove_env_value = late("remove_env_value")
save_config = late("save_config")
save_env_value = late("save_env_value")
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
# 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",
@@ -59,13 +56,32 @@ _CATEGORY_ORDER = [
]
@contextlib.contextmanager
def _env_write_errors(log_msg: str, *, http_passthrough: bool):
"""``ValueError`` -> 400 with its message (save/remove_env_value reject
invalid names and denylisted keys — LD_PRELOAD, PATH, PYTHONPATH, …, and
the SPA needs the reason, not an opaque 500); anything else is logged and
becomes 500 "Internal server error"."""
try:
yield
except HTTPException:
if http_passthrough:
raise
_log.exception(log_msg)
raise HTTPException(status_code=500, detail="Internal server error")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception:
_log.exception(log_msg)
raise HTTPException(status_code=500, detail="Internal server error")
@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.
# load_config() reads from disk; a slow lock-holder on the event loop froze
# the whole gateway for >1s. 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())
@@ -100,28 +116,23 @@ async def get_egress_status():
@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.
# The dashboard form is schema-driven; root keys absent from the
# schema (``custom_providers``, ``agent.personalities``, ...) are not
# in the PUT body, so deep-merge incoming over disk rather than
# full-replace — the frontend can only overwrite what it 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.
# documents, not config blocks and not cache re-reads: the page
# PUTs the defaulted GET record while disk holds sparse YAML (a
# block compare is always-unequal), and a post-save reload can
# serve the pre-save cache on an (mtime_ns, size) collision.
# Only approvals.mode feeds session.info, so it is the 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
@@ -136,35 +147,38 @@ async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
return await asyncio.to_thread(_run)
def _provider_card(d, description: str, url, *, is_password: bool, advanced: bool) -> dict:
return {
"provider": d.slug,
"provider_label": d.label,
"description": description,
"url": url,
"is_password": is_password,
"advanced": advanced,
"category": "provider",
}
def _catalog_provider_env_metadata() -> dict:
"""Map provider env vars → desktop card metadata, derived from the catalog.
"""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.
(the ``hermes model`` universe), so the desktop Keys tab renders a card even
for providers never hand-added to ``OPTIONAL_ENV_VARS``. Hand
``OPTIONAL_ENV_VARS`` prose is layered on top in the endpoint; this only
supplies membership + grouping + 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 = {}
# Env vars declared with a NON-provider category (e.g. the shared
# GITHUB_TOKEN, a Skills-Hub "tool" credential) must not be promoted into a
# provider card even when a provider (Copilot) lists them as auth aliases.
_non_provider_keys = {
k for k, v in _OPT.items()
k for k, v in OPTIONAL_ENV_VARS.items()
if (v or {}).get("category") and (v or {}).get("category") != "provider"
}
@@ -172,74 +186,45 @@ def _catalog_provider_env_metadata() -> 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.
# API-key vars: the first is the primary (password) field; 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",
},
_provider_card(d, d.description, d.signup_url or None, is_password=True, advanced=False),
)
# 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",
},
_provider_card(d, f"{d.label} base URL override", None, is_password=False, advanced=True),
)
# 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).
# Providers without api_key_env_vars would otherwise be invisible on
# the Keys tab: AWS-SDK providers (Bedrock) authenticate via the AWS
# credential chain, Vertex via OAuth2 (service-account JSON path or
# ADC — a path, not a secret). Tag their env vars to the card.
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.
meta[aws_var] = _provider_card(
d,
existing.get("description") or f"{d.label} ({aws_var})",
existing.get("url"),
is_password=False,
advanced=existing.get("advanced", True),
)
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",
}
meta["VERTEX_CREDENTIALS_PATH"] = _provider_card(
d,
existing.get("description") or f"{d.label} — service account JSON path (or use ADC)",
existing.get("url"),
is_password=False,
advanced=existing.get("advanced", True),
)
return meta
@@ -270,38 +255,29 @@ def _get_env_vars_sync(profile: Optional[str] = None):
"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.
# Messaging-platform credential owned by a Channels page card; the
# Keys/Env page hides it rather than duplicate the richer 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 grouping from the unified catalog, so the desktop groups
# by the SAME provider identity the CLI `hermes model` picker uses.
"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.
# True for a .env key in no catalog at all — an arbitrary/custom var
# the user added directly, listed so the Keys page can manage it.
"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.
# Catalog provider env vars with no hand entry in OPTIONAL_ENV_VARS.
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.
# Custom keys from .env: always "set" (on disk), treated as secrets by
# default (is_password=True -> redacted, reveal-gated) since an
# unrecognised key could hold anything. Channel-managed credentials belong
# to the Channels page. A key added via "add a custom key" round-trips here.
for var_name in env_on_disk:
if var_name in result or var_name in channel_keys:
continue
@@ -318,31 +294,22 @@ async def set_env_var(body: EnvVarUpdate, profile: Optional[str] = None):
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).
# (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.
from hermes_cli.credential_lifecycle import save_provider_env_credential
return save_provider_env_credential(body.key, body.value)
try:
with _env_write_errors("PUT /api/env failed", http_passthrough=False):
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".
# Live credential probes keyed by env var: (url, auth) where auth is "bearer"
# (Authorization header) or "query" (?key=). A cheap read-only call that 401s
# on a bad token — enough to catch a mistyped key before it's persisted.
# Providers absent here (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"),
@@ -359,9 +326,7 @@ def _custom_endpoint_id(raw: str, fallback: str = "custom") -> str:
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):
if isinstance(raw_models, (dict, list)):
models.extend(str(model).strip() for model in raw_models)
default_model = str(entry.get("model") or entry.get("default_model") or "").strip()
@@ -375,10 +340,8 @@ def _models_from_custom_endpoint_entry(entry: Dict[str, Any]) -> List[str]:
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``.
Keys live in ``.env`` behind ``key_env``; only older entries still carry a
plaintext ``api_key``. Checking both keeps the panel honest either way.
"""
plaintext = str(entry.get("api_key") or "").strip()
if plaintext:
@@ -389,18 +352,21 @@ def _api_key_display(entry: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
return False, None
def _raw_provider_api_key(endpoint_id: str) -> str:
"""The on-disk (un-expanded) ``api_key`` of a providers entry, or ``""``."""
_stored, entry = find_provider_entry(read_raw_config().get("providers"), endpoint_id)
return str(entry.get("api_key") or "").strip() if isinstance(entry, dict) else ""
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.
``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 already keeps 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))
return bool(re.search(r"\$\{[^}]+\}", _raw_provider_api_key(endpoint_id)))
def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]:
@@ -466,14 +432,10 @@ def _detach_main_model_from_provider(cfg: Dict[str, Any], provider_key: str) ->
"""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.
``api_key`` onto ``model``; that mirror outranks the environment at client
construction, so deleting the endpoint without clearing it leaves the agent
authenticating to the deleted host with the deleted key (and the key in
config.yaml). Only touches ``model`` when it names the deleted provider.
"""
model_cfg = cfg.get("model")
if not isinstance(model_cfg, dict):
@@ -508,13 +470,11 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
if existing is None:
existing = {}
# Merge onto the existing entry rather than replacing it. A providers.<name>
# 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.
# Merge onto the existing entry rather than replacing it: a providers.<name>
# block can carry hand-written keys the dashboard has no field for
# (``api_mode``, ``key_env``/``api_key_env``, ``extra_headers`` — possibly
# with credentials — ``request_overrides``); rebuilding from scratch
# silently dropped them on an unrelated edit.
entry: Dict[str, Any] = dict(existing)
entry.update({
"name": name,
@@ -522,12 +482,10 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
"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.
# Same for the model map, so existing models keep their context lengths.
# ``body.models`` is the catalogue the panel's Test button discovered;
# without it only the hand-typed model survived Save. A payload with no
# ``models`` (older UI) still 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):
@@ -541,9 +499,9 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
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.
# API keys never belong in config.yaml: write to .env and reference it via
# ``key_env`` — the indirection built-in providers use and that
# runtime_provider.py 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:
@@ -556,10 +514,8 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
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.
# Migrate a plaintext key an earlier release wrote, on the next save,
# without the user having to re-enter it.
save_env_value(env_var, entry["api_key"].strip())
entry["key_env"] = env_var
entry.pop("api_key", None)
@@ -584,10 +540,9 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
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.
Scoped to the requested profile's config.yaml: 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``).
"""
with http_failure("GET /api/providers/custom-endpoints failed", 500, detail="Failed to list custom endpoints"):
with _config_profile_scope(profile):
@@ -611,7 +566,10 @@ def upsert_custom_endpoint(body: CustomEndpointUpdate, profile: Optional[str] =
@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 http_failure(
f"POST /api/providers/custom-endpoints/{endpoint_id}/activate failed", 500,
detail="Failed to activate custom endpoint",
):
with _config_profile_scope(profile):
cfg = load_config()
provider_key = _custom_endpoint_id(endpoint_id)
@@ -630,21 +588,12 @@ def activate_custom_endpoint(endpoint_id: str, profile: Optional[str] = None):
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
# `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.
try:
_stored_raw, _raw_entry = find_provider_entry(
read_raw_config().get("providers"), provider_key
)
_raw_key = _raw_provider_api_key(provider_key)
except Exception:
_raw_entry = None
_raw_key = (
str(_raw_entry.get("api_key") or "").strip()
if isinstance(_raw_entry, dict)
else ""
)
_raw_key = ""
if _raw_key.startswith("${") and _raw_key.endswith("}"):
model_cfg["api_key"] = _raw_key
else:
@@ -652,17 +601,15 @@ def activate_custom_endpoint(endpoint_id: str, profile: Optional[str] = None):
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 http_failure(
f"DELETE /api/providers/custom-endpoints/{endpoint_id} failed", 500,
detail="Failed to delete custom endpoint",
):
with _config_profile_scope(profile):
cfg = load_config()
provider_key = _custom_endpoint_id(endpoint_id)
@@ -678,11 +625,6 @@ def delete_custom_endpoint(endpoint_id: str, profile: Optional[str] = None):
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")
@@ -731,14 +673,12 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
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.
# response (even 401) proves the endpoint is up. Also surface the model ids
# it advertises (OpenAI ``/v1/models`` shape) so the GUI can auto-pick a
# default. The optional API key is sent so servers that require auth on
# ``/v1/models`` still enumerate instead of returning an empty list.
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:
@@ -781,29 +721,19 @@ async def remove_env_var(body: EnvVarDelete, profile: Optional[str] = None):
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.
# auth.json (stale ones kept providers alive in the model picker),
# 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:
with _env_write_errors("DELETE /api/env failed", http_passthrough=True):
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")
@@ -812,15 +742,11 @@ async def reveal_env_var(
):
"""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
Protected by the ephemeral session token (per server start, injected into
the SPA), rate limiting (max 5 reveals per 30s window) and 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]
@@ -828,7 +754,6 @@ async def reveal_env_var(
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()