From d02762ccca27accbc2b0c0b26af94b9659db1c32 Mon Sep 17 00:00:00 2001 From: Konstantin Khlopkov Date: Fri, 18 Sep 2026 21:01:24 +0300 Subject: [PATCH] fix(models): credit the managed local-models library in /model validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validation ladder had no llamacpp branch: a switch to a freshly downloaded local model fell through to the generic live-listing probe, which hard-rejects when the spawn-only /v1/models listing has not learned the new file yet — so the Local Models Use button and the composer picker could never succeed for a non-catalog model. The managed runtime now validates against the staged library on disk (the source of truth for what the user downloaded), case-insensitively; ids that were never staged keep the live-listing verdict. The activate flow's self-heal had the same blind spot: its rescan only ran when this process supervised the server, but ensure_local_runtime returns None when another process owns it — precisely the desktop situation after a download job bounced the router once. Probe the live listing through the persisted endpoint and bounce the router when it lacks the model. --- hermes_cli/models_validate.py | 25 ++++- hermes_cli/web_routers/local_models.py | 18 +++- .../test_local_models_activate_rescan.py | 90 +++++++++++++++++ .../test_model_validation_managed_local.py | 98 +++++++++++++++++++ 4 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 tests/hermes_cli/test_local_models_activate_rescan.py create mode 100644 tests/hermes_cli/test_model_validation_managed_local.py diff --git a/hermes_cli/models_validate.py b/hermes_cli/models_validate.py index 4a39d15db4..0deaaff315 100644 --- a/hermes_cli/models_validate.py +++ b/hermes_cli/models_validate.py @@ -449,6 +449,26 @@ def _profile_owns_catalog(normalized: str) -> bool: type(profile).fetch_models is not ProviderProfile.fetch_models or bool(profile.models_url)) +def _validate_managed_local(req: _Request) -> Optional[dict[str, Any]]: + """The managed llama.cpp runtime: the staged library on disk is the source of truth, not the + live listing. The router's model list is spawn-only (a GGUF landed after its start is + invisible to GET /models until a bounce), so validating a freshly downloaded model against + the live listing rejects the very file the user just staged — the Local Models "Use" flow + and the composer picker could never succeed for a non-catalog model. A staged id accepts + (case-insensitive: typing matches the file name, the router registers the preset id); + anything else falls through to the live listing, which stays authoritative for ids that + were never downloaded here.""" + from hermes_cli.local_runtime.bootstrap import staged_model_ids + + staged = {sid.lower() for sid in staged_model_ids()} + if req.lookup.strip().lower() in staged: + return _accept_with_note( + f"Note: `{req.requested}` was not found in the live /v1/models listing " + "but is downloaded in the managed local-models library — accepted." + ) + return None + + def _validate_live_listing(req: _Request) -> Optional[dict[str, Any]]: """Generic live /v1/models probe. Returns None when the API was unreachable (the caller then tries Bedrock discovery / the curated catalog). A profile that owns its catalog is validated @@ -557,8 +577,8 @@ def _for(*providers: str) -> Callable[[_Request], bool]: # (gate, branch): the branch runs when the gate passes; the first non-None verdict wins. ORDER IS # BEHAVIOR: moa → whitespace → OpenRouter preset parse → LM Studio → Ollama native → custom → -# codex/xai static → MiniMax → Anthropic native → Anthropic Messages → live listing → Bedrock → -# curated-catalog fallback (always decides). +# codex/xai static → MiniMax → managed local (staged library) → Anthropic native → +# Anthropic Messages → live listing → Bedrock → curated-catalog fallback (always decides). _LADDER: tuple[tuple[Callable[[_Request], bool], Callable[[_Request], Optional[dict[str, Any]]]], ...] = ( (_for("moa"), _validate_moa), (lambda req: True, _reject_whitespace), @@ -568,6 +588,7 @@ _LADDER: tuple[tuple[Callable[[_Request], bool], Callable[[_Request], Optional[d (_is_custom, _validate_custom), (_for("openai-codex", "xai-oauth"), _validate_static_catalog), (_for("minimax", "minimax-cn"), _validate_minimax), + (_for("llamacpp", "llama.cpp", "llama-cpp"), _validate_managed_local), (_for("anthropic"), _validate_anthropic), (lambda req: req.api_mode == "anthropic_messages", _validate_anthropic_messages), (lambda req: True, _validate_live_listing), diff --git a/hermes_cli/web_routers/local_models.py b/hermes_cli/web_routers/local_models.py index 907123778a..6113f04324 100644 --- a/hermes_cli/web_routers/local_models.py +++ b/hermes_cli/web_routers/local_models.py @@ -263,13 +263,25 @@ def _ensure_server(job: Dict[str, Any], config: dict, model_id: str, *, fail_det _step(job, "starting-server", "Starting the local server") sup = _start_local_server(config, fail_detail) - def rescan_if_unknown() -> None: - if model_id not in sup.models(): + def rescan_if_unknown(known: Dict[str, Any]) -> None: + if model_id not in known: job["detail"] = "Refreshing the local server" bootstrap.refresh_local_runtime() if sup is not None: - _quiet(rescan_if_unknown, None, debug=skip_msg) + _quiet(lambda: rescan_if_unknown(sup.models()), None, debug=skip_msg) + return + # A server owned by another process: ensure_local_runtime returned None, so the supervisor + # path above never ran — but the same spawn-only listing gap applies. Probe the live + # listing through the persisted endpoint and bounce when it lacks the model. + endpoint = _state_endpoint() + if endpoint is not None: + + def _known_models() -> Dict[str, Any]: + data = (_router_request(endpoint, "/models", timeout=10) or {}).get("data", []) + return {m.get("id"): m for m in data} + + _quiet(lambda: rescan_if_unknown(_known_models()), None, debug=skip_msg) def _assign_default(job: Dict[str, Any], model_id: str) -> None: diff --git a/tests/hermes_cli/test_local_models_activate_rescan.py b/tests/hermes_cli/test_local_models_activate_rescan.py new file mode 100644 index 0000000000..5fe2b1825e --- /dev/null +++ b/tests/hermes_cli/test_local_models_activate_rescan.py @@ -0,0 +1,90 @@ +"""The activate flow's rescan must fire even when another process owns the local server (#115237). + +ensure_local_runtime returns None when the router is supervised elsewhere, so the supervisor +path's rescan_if_unknown never ran — exactly the desktop situation after a download job already +bounced the router once (presets fresh, listing still spawn-only and missing the new model). +""" + +from __future__ import annotations + +from unittest.mock import patch + + +def test_activate_rescans_through_state_endpoint_when_supervisor_is_none( + tmp_path, monkeypatch +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_cli.web_routers import local_models as lm + + staged = tmp_path / "models" + staged.mkdir(parents=True) + (staged / "Qwen3-4B-Q4_K_M.gguf").write_bytes(b"GGUF\x00\x00\x00\x00") + + calls = {"refresh": 0, "known": [{"id": "some-other-model"}]} + + def fake_refresh(): + calls["refresh"] += 1 + + def fake_router_request(endpoint, path, *, timeout, payload=None): + assert path == "/models" + return {"data": calls["known"]} + + job = lm._job("model-activate", "Qwen3-4B-Q4_K_M", model_id="Qwen3-4B-Q4_K_M") + with ( + patch.object(lm.bootstrap, "ensure_local_runtime", return_value=None), + patch.object( + lm, + "_state_endpoint", + return_value={"base_url": "http://127.0.0.1:18434/v1", "api_key": "k"}, + ), + patch.object(lm.bootstrap, "refresh_local_runtime", fake_refresh), + patch.object(lm, "_router_request", fake_router_request), + patch.object(lm, "_set_runtime_enabled", lambda v: {}), + patch( + "hermes_cli.web_server_config._apply_model_assignment_sync", + lambda *a, **k: {"ok": True}, + ), + ): + lm._ensure_server( + job, + {"local_runtime": {"enabled": True}}, + "Qwen3-4B-Q4_K_M", + fail_detail="server failed", + skip_msg="skipped", + ) + + assert calls["refresh"] == 1, ( + "the router must be bounced when its listing lacks the model" + ) + + +def test_activate_skips_rescan_when_owned_server_already_lists_the_model( + tmp_path, monkeypatch +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_cli.web_routers import local_models as lm + + calls = {"refresh": 0} + + class FakeSup: + def models(self): + return {"Qwen3-4B-Q4_K_M": "loaded"} + + job = lm._job("model-activate", "Qwen3-4B-Q4_K_M", model_id="Qwen3-4B-Q4_K_M") + with ( + patch.object(lm.bootstrap, "ensure_local_runtime", return_value=FakeSup()), + patch.object( + lm.bootstrap, + "refresh_local_runtime", + lambda: calls.__setitem__("refresh", calls["refresh"] + 1), + ), + ): + lm._ensure_server( + job, + {"local_runtime": {"enabled": True}}, + "Qwen3-4B-Q4_K_M", + fail_detail="server failed", + skip_msg="skipped", + ) + + assert calls["refresh"] == 0, "no bounce when the listing already knows the model" diff --git a/tests/hermes_cli/test_model_validation_managed_local.py b/tests/hermes_cli/test_model_validation_managed_local.py new file mode 100644 index 0000000000..20b517fde4 --- /dev/null +++ b/tests/hermes_cli/test_model_validation_managed_local.py @@ -0,0 +1,98 @@ +"""Tests for the managed llama.cpp runtime branch in /model validation (#115237).""" + +from __future__ import annotations + +from unittest.mock import patch + +from hermes_cli.models_validate import validate_requested_model + + +def _validate(model, staged=("Qwen3-4B-Q4_K_M",), live=("other-model",), **kw): + """validate against a managed-local world: staged files on disk + a live listing that + (spawn-only as it is) hasn't learned the new file yet.""" + with ( + patch("hermes_cli.models.fetch_api_models", return_value=list(live)), + patch( + "hermes_cli.models.probe_api_models", + return_value={ + "models": list(live), + "probed_url": "http://127.0.0.1:18434/v1/models", + "resolved_base_url": "http://127.0.0.1:18434/v1", + "suggested_base_url": None, + "used_fallback": False, + }, + ), + patch( + "hermes_cli.local_runtime.bootstrap.staged_model_ids", + return_value=list(staged), + ), + ): + return validate_requested_model( + model, "llamacpp", base_url="http://127.0.0.1:18434/v1", **kw + ) + + +class TestManagedLocalValidation: + def test_staged_but_not_live_accepted(self): + """The Use button's exact case: downloaded, not yet in the spawn-only live listing.""" + result = _validate("Qwen3-4B-Q4_K_M") + assert (result["accepted"], result["persist"], result["recognized"]) == ( + True, + True, + True, + ) + assert "managed local-models library" in result["message"] + + def test_typed_case_matches_staged_spelling(self): + result = _validate("qwen3-4b-q4_k_m") + assert result["accepted"] is True + assert result["recognized"] is True + + def test_not_staged_and_not_live_still_rejected(self): + result = _validate("Llama-4-90B-Q4_K_M", staged=()) + assert result["accepted"] is False + assert "was not found in this provider's model listing" in result["message"] + + def test_live_hit_still_wins(self): + """A model the router already serves validates through the live listing, staged or not.""" + result = _validate("other-model", staged=()) + assert (result["accepted"], result["persist"], result["recognized"]) == ( + True, + True, + True, + ) + assert result["message"] is None + + def test_aliases_route_to_the_same_branch(self): + for alias in ("llama.cpp", "llama-cpp"): + result = _validate("Qwen3-4B-Q4_K_M") + assert result["accepted"] is True, alias + + def test_other_providers_do_not_read_the_staged_library(self): + """The branch is llamacpp-only: a cloud provider must keep its live-listing verdict.""" + with ( + patch("hermes_cli.models.fetch_api_models", return_value=["deepseek-v4.1"]), + patch( + "hermes_cli.models.probe_api_models", + return_value={ + "models": ["deepseek-v4.1"], + "probed_url": "x", + "resolved_base_url": "x", + "suggested_base_url": None, + "used_fallback": False, + }, + ), + patch( + "hermes_cli.local_runtime.bootstrap.staged_model_ids", + return_value=["Qwen3-4B-Q4_K_M"], + ), + ): + result = validate_requested_model( + "Qwen3-4B-Q4_K_M", "deepseek", base_url="https://api.deepseek.com/v1" + ) + assert result["accepted"] is False + + def test_empty_staged_library_falls_through(self): + """No staged files: nothing to credit, the live listing decides (None fall-through).""" + result = _validate("other-model", staged=()) + assert result["accepted"] is True # live hit via _validate_live_listing