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:
@@ -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()
|
||||
|
||||
82
tests/hermes_cli/test_model_set_preserve_custom_endpoint.py
Normal file
82
tests/hermes_cli/test_model_set_preserve_custom_endpoint.py
Normal 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"
|
||||
Reference in New Issue
Block a user