diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 4bf75fe3117..8bcea9214d5 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -2213,6 +2213,7 @@ def list_authenticated_providers( "api_url": api_url, "api_key": api_key, "models": [], + "has_explicit_models": False, "discover_models": discover, "extra_headers": entry_extra_headers, } @@ -2238,6 +2239,7 @@ def list_authenticated_providers( for model_id in _declared_model_ids(entry.get("models", {})): if model_id not in groups[group_key]["models"]: groups[group_key]["models"].append(model_id) + groups[group_key]["has_explicit_models"] = True _section4_emitted_slugs: set = set() _current_base_url_group_count = sum( @@ -2298,11 +2300,13 @@ def list_authenticated_providers( # the (possibly partial) ``models:`` subset configured for # context-length overrides with the full live catalog. # This is the Bifrost / aggregator-gateway case. - # - Without an api_key but with an explicit ``models:`` list - # (or top-level ``model:``), the user is narrowing a public - # endpoint to a specific subset (e.g. ollama.com /v1/models - # returns 35 models but the user only wants 4). Preserve the - # explicit list and skip live discovery. + # - Without an api_key but with an explicit ``models:`` list, + # the user is narrowing a public endpoint to a specific subset + # (e.g. ollama.com /v1/models returns 35 models but the user + # only wants 4). Preserve the explicit list and skip live + # discovery. The singular ``model:`` field is only the current + # active selection and must not suppress discovery on local + # no-key endpoints. # - Without an api_key AND no explicit models, fall through to # live discovery so bare-endpoint custom providers (local # llama.cpp / Ollama servers) still appear populated. @@ -2320,7 +2324,7 @@ def list_authenticated_providers( should_probe = ( _can_probe_custom_provider(row_is_current=_grp_is_current) and bool(api_url) - and (bool(api_key) or not grp["models"]) + and (bool(api_key) or not grp.get("has_explicit_models")) and grp.get("discover_models", True) ) if should_probe: diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 4d80469b975..b0ad196867c 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -5,11 +5,18 @@ shared slash-command pipeline (`/model` in CLI/gateway/Telegram) historically only looked at `providers:`. """ +import pytest + import hermes_cli.providers as providers_mod from hermes_cli.model_switch import list_authenticated_providers, switch_model from hermes_cli.providers import resolve_provider_full +@pytest.fixture(autouse=True) +def _no_live_custom_provider_model_fetch(monkeypatch): + monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *args, **kwargs: []) + + _MOCK_VALIDATION = { "accepted": True, "persist": True, @@ -329,6 +336,42 @@ def test_list_deduplicates_same_model_in_group(monkeypatch): assert my_rows[0]["total_models"] == 2 +def test_custom_provider_no_key_singular_model_still_probes_live_models(monkeypatch): + """A singular ``model:`` is the active selection, not an explicit catalog. + + No-key local endpoints such as Ollama and llama.cpp should still be probed + so /model matches the terminal ``hermes model`` flow. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + calls = [] + + def fake_fetch_api_models(api_key, base_url, **kwargs): + calls.append((api_key, base_url, kwargs)) + return ["llama3", "mistral", "qwen3-coder"] + + monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models) + + providers = list_authenticated_providers( + current_provider="openai-codex", + user_providers={}, + custom_providers=[ + { + "name": "Local Ollama", + "base_url": "http://localhost:11434/v1", + "model": "llama3", + } + ], + max_models=50, + ) + + assert calls == [("", "http://localhost:11434/v1", {"headers": None})] + row = next(p for p in providers if p["name"] == "Local Ollama") + assert row["models"] == ["llama3", "mistral", "qwen3-coder"] + assert row["total_models"] == 3 + + def test_list_enumerates_dict_format_models_alongside_default(monkeypatch): """custom_providers entry with dict-format ``models:`` plus singular ``model:`` should surface the default and every dict key.