fix(dashboard): preserve CLI-configured custom endpoints on same-endpoint model/set

POST /api/model/set re-registered a named custom_providers row for any
custom/local main assignment, even when the endpoint was already a fact
in config.yaml (the CLI gateway setup writes provider: custom with an
inline base_url + api_mode). The picker then rewrote the CLI's shape into
custom:<slug> with base_url: '' (#76324).

Snapshot the pre-save config and skip registration when the endpoint is
already known (inline model.base_url, a providers: entry, or a
custom_providers row); only a genuinely new endpoint gets a named row.

Fixes #76324
This commit is contained in:
Austin Pickett
2026-10-06 12:01:29 -04:00
parent 5515c4be55
commit da303d1729
2 changed files with 122 additions and 2 deletions

View File

@@ -661,10 +661,41 @@ def _apply_nous_gateway_defaults(cfg: dict) -> list:
return []
def _endpoint_known_to_config(cfg: dict, base_url: str) -> bool:
"""True when *base_url* is already an endpoint fact in *cfg*: the inline ``model.base_url``,
a ``providers.<slug>`` entry, or a ``custom_providers`` row. Comparison is trailing-slash
and case insensitive (``_save_custom_provider`` dedups the same way)."""
wanted = str(base_url or "").strip().rstrip("/").lower()
if not wanted:
return False
def _matches(value: Any) -> bool:
return str(value or "").strip().rstrip("/").lower() == wanted
model_cfg = cfg.get("model")
if isinstance(model_cfg, dict) and _matches(model_cfg.get("base_url")):
return True
for section_key in ("providers", "custom_providers"):
section = cfg.get(section_key)
entries: Any = section if isinstance(section, list) else (
list(section.values()) if isinstance(section, dict) else [])
for entry in entries:
if isinstance(entry, dict) and _matches(entry.get("base_url") or entry.get("url") or entry.get("api")):
return True
return False
def _register_custom_endpoint(base_url: str, api_key: str, model: str) -> None:
"""Register a named ``custom_providers`` entry for a custom/local endpoint (mirrors the
``hermes model`` custom flow) so the picker gets a proper ready row instead of a "needs
setup" dead-end. Dedups by base_url; never blocks the already-persisted assignment."""
setup" dead-end. Dedups by base_url; never blocks the already-persisted assignment.
SKIPPED when the endpoint is already a fact in the config (#76324): the CLI gateway setup
writes ``provider: custom`` with an inline ``base_url`` + ``api_mode``, and re-registering
that same endpoint as a named ``custom:<slug>`` row on every dashboard re-save is what let
the picker rewrite the CLI's shape into ``custom:<slug>`` + ``base_url: ''``. Only a
GENUINELY new endpoint gets a named row.
"""
try:
from hermes_cli.main_provider_setup import _auto_provider_name, _save_custom_provider
@@ -728,6 +759,13 @@ def _apply_main_assignment_sync(cfg: dict, provider: str, model: str, base_url:
base_url, result = prepared or _prepare_main_assignment(cfg, provider, model, base_url, api_key)
provider, model = result.target_provider, result.new_model
provider_entry = _provider_entry(cfg, provider)
# Snapshot BEFORE the new assignment overwrites cfg["model"]: this is the state the user
# (CLI setup, a prior dashboard save) already had on disk, and it decides whether the
# endpoint is genuinely NEW (register a named custom_providers row) or already known
# (keep the existing shape — #76324).
provider_lc = provider.strip().lower()
endpoint_already_known = (
provider_lc in {"custom", "local"} and bool(base_url) and _endpoint_known_to_config(cfg, base_url))
model_cfg = _apply_main_model_assignment(cfg.get("model", {}), result, api_key)
_resolve_assignment_credentials(model_cfg, provider, provider_entry)
cfg["model"] = model_cfg
@@ -735,7 +773,7 @@ def _apply_main_assignment_sync(cfg: dict, provider: str, model: str, base_url:
new_provider = provider.strip().lower()
gateway_tools = _apply_nous_gateway_defaults(cfg) if new_provider == "nous" else []
save_config(cfg)
if new_provider in {"custom", "local"} and base_url:
if new_provider in {"custom", "local"} and base_url and not endpoint_already_known:
_register_custom_endpoint(base_url, api_key, model)
# The serve process's boot record may still say "nothing configured"; the chat gates on it.
reconcile_record()

View File

@@ -0,0 +1,82 @@
"""Red test for #76324: POST /api/model/set preserving a CLI-configured bare ``custom`` endpoint.
The CLI gateway setup writes::
model:
default: gemma4:latest
provider: custom
base_url: http://10.0.0.155:11434/v1
api_mode: chat_completions
Re-saving the SAME endpoint through the dashboard must leave this shape on disk
untouched: no ``custom:<slug>`` provider rewrite, no empty ``base_url: ''``,
no auto-registered ``custom_providers`` entry.
"""
import importlib
import sys
import pytest
@pytest.fixture()
def isolated_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
import hermes_cli.config as config_mod
importlib.reload(config_mod)
yield home
importlib.reload(config_mod)
def _write_cli_config(home):
(home / "config.yaml").write_text(
"model:\n"
" default: gemma4:latest\n"
" provider: custom\n"
" base_url: http://10.0.0.155:11434/v1\n"
" api_mode: chat_completions\n",
encoding="utf-8",
)
def _model_cfg(home):
import hermes_yaml as yaml
return (yaml.safe_load((home / "config.yaml").read_text(encoding="utf-8")) or {}).get("model", {})
def test_same_endpoint_model_set_preserves_bare_custom_block(isolated_home):
from hermes_cli.web_server_config import _apply_model_assignment_sync
_write_cli_config(isolated_home)
# Dashboard re-save of the same endpoint (base_url sent along).
_apply_model_assignment_sync(
"main", "custom", "gemma4:latest", "", "http://10.0.0.155:11434/v1", "")
model = _model_cfg(isolated_home)
assert model.get("default") == "gemma4:latest"
assert model.get("provider") == "custom"
assert model.get("base_url") == "http://10.0.0.155:11434/v1"
assert model.get("api_mode") == "chat_completions"
raw = (isolated_home / "config.yaml").read_text(encoding="utf-8")
assert "custom_providers" not in raw
def test_new_endpoint_model_set_still_registers_named_provider(isolated_home):
"""The guard must not over-fix: introducing a GENUINELY new custom endpoint through the
dashboard still registers a named ``custom_providers`` row (the picker's ready row)."""
import hermes_yaml as yaml
from hermes_cli.web_server_config import _apply_model_assignment_sync
_write_cli_config(isolated_home)
_apply_model_assignment_sync(
"main", "custom", "other-model", "", "http://192.168.1.50:11434/v1", "")
cfg = yaml.safe_load((isolated_home / "config.yaml").read_text(encoding="utf-8")) or {}
entries = [e for e in (cfg.get("custom_providers") or []) if isinstance(e, dict)]
assert any(e.get("base_url") == "http://192.168.1.50:11434/v1" for e in entries)
# The model block now points at the new endpoint, not the old one.
assert _model_cfg(isolated_home).get("base_url") == "http://192.168.1.50:11434/v1"