diff --git a/hermes_cli/web_server_config.py b/hermes_cli/web_server_config.py index 1847ecc319..6de516ba35 100644 --- a/hermes_cli/web_server_config.py +++ b/hermes_cli/web_server_config.py @@ -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.`` 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:`` row on every dashboard re-save is what let + the picker rewrite the CLI's shape into ``custom:`` + ``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() diff --git a/tests/hermes_cli/test_model_set_preserve_custom_endpoint.py b/tests/hermes_cli/test_model_set_preserve_custom_endpoint.py new file mode 100644 index 0000000000..52277bccf1 --- /dev/null +++ b/tests/hermes_cli/test_model_set_preserve_custom_endpoint.py @@ -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:`` 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"