diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 0c4d8c0148e..ba60f53a6b2 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -2453,11 +2453,17 @@ def list_authenticated_providers( if custom_providers and isinstance(custom_providers, list): from collections import OrderedDict - # Key by endpoint + credential identity + wire protocol instead of - # slug: names frequently differ per model ("Ollama — X") while the - # endpoint stays the same. Keep same-host providers with distinct - # env-backed credentials or API protocols separate so picker selection - # cannot route through the wrong credential/mode pair. + # Key by endpoint + credential identity + wire protocol + display + # prefix instead of slug: names frequently differ per model + # ("Ollama — X") while the endpoint stays the same. Keep same-host + # providers with distinct env-backed credentials or API protocols + # separate so picker selection cannot route through the wrong + # credential/mode pair. The display prefix (text before " — " / + # " - ") is included so intentionally distinct providers sharing an + # endpoint (e.g. a proxy fronting cerebras, groq and perplexity at + # a single base_url) each get their own picker row instead of + # collapsing into one. Per-model suffix entries that share the same + # prefix ("Ollama — A", "Ollama — B") still group together. groups: "OrderedDict[tuple, dict]" = OrderedDict() for entry in custom_providers: if not isinstance(entry, dict): @@ -2504,19 +2510,19 @@ def list_authenticated_providers( entry_extra_headers = _extra_headers_from_config(entry) headers_identity = tuple(sorted(entry_extra_headers.items())) - group_key = (api_url, credential_identity, api_mode, headers_identity) + # Display-name prefix (text before " — " / " - "), used both + # as a grouping dimension and to derive the row's display name. + _display_prefix = raw_name + for sep in ("—", " - "): + if sep in _display_prefix: + _display_prefix = _display_prefix.split(sep)[0].strip() + break + + group_key = (api_url, credential_identity, api_mode, headers_identity, _display_prefix.lower()) if group_key not in groups: - # Strip per-model suffix so "Ollama — GLM 5.1" becomes - # "Ollama" for the grouped row. Em dash is the convention - # Hermes's own writer uses; a hyphen variant is accepted - # for hand-edited configs. - display_name = raw_name - for sep in ("—", " - "): - if sep in display_name: - display_name = display_name.split(sep)[0].strip() - break - if not display_name: - display_name = raw_name + # Reuse the prefix computed above as the row display name; + # fall back to the raw name if stripping left it empty. + display_name = _display_prefix or raw_name slug = custom_provider_slug(display_name) groups[group_key] = { "slug": slug, diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 4bf57a2f345..a1f03bfcad6 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -1557,3 +1557,77 @@ def test_save_discovered_models_preserves_list_of_dicts_form(monkeypatch): assert save_calls == [], ( "List-of-dicts models must not be replaced with a flat list" ) + + +def test_shared_url_different_display_names_are_separate_rows(monkeypatch): + """Multiple custom_providers entries sharing base_url + api_key + api_mode + but with *different* display-name prefixes (e.g. a proxy fronting + cerebras, groq and perplexity at one URL) must each get their own picker + row, not collapse into one.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + # Stub live discovery so the test is deterministic regardless of network. + monkeypatch.setattr( + "hermes_cli.models.fetch_api_models", + lambda api_key, base_url, **kwargs: [], + ) + + providers = list_authenticated_providers( + current_provider="openrouter", + current_base_url="https://openrouter.ai/api/v1", + user_providers={}, + custom_providers=[ + {"name": "Cerebras", "base_url": "https://proxy.example.com/v1", + "api_key": "proxy-key", "model": "llama-4-scout"}, + {"name": "Groq", "base_url": "https://proxy.example.com/v1", + "api_key": "proxy-key", "model": "llama-4-scout"}, + {"name": "Perplexity", "base_url": "https://proxy.example.com/v1", + "api_key": "proxy-key", "model": "sonar-pro"}, + ], + max_models=50, + ) + + custom = [p for p in providers if p.get("is_user_defined")] + names = sorted(p["name"] for p in custom) + assert names == ["Cerebras", "Groq", "Perplexity"], ( + f"expected three separate rows, got {names}" + ) + # Each row carries only its own model (no cross-contamination). + by_name = {p["name"]: p["models"] for p in custom} + assert by_name["Cerebras"] == ["llama-4-scout"] + assert by_name["Groq"] == ["llama-4-scout"] + assert by_name["Perplexity"] == ["sonar-pro"] + + +def test_shared_url_per_model_suffix_still_collapses(monkeypatch): + """Per-model suffix entries sharing the same display-name prefix (e.g. + "Ollama — A", "Ollama — B") must still collapse into one row even with + the display-prefix grouping dimension.""" + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + # Stub live discovery so a locally-running Ollama cannot override the + # static configured models and make the assertion flaky. + monkeypatch.setattr( + "hermes_cli.models.fetch_api_models", + lambda api_key, base_url, **kwargs: [], + ) + + providers = list_authenticated_providers( + current_provider="openrouter", + current_base_url="https://openrouter.ai/api/v1", + user_providers={}, + custom_providers=[ + {"name": "Ollama \u2014 GLM 5.1", "base_url": "http://localhost:11434/v1", + "api_key": "ollama", "model": "glm-5.1"}, + {"name": "Ollama \u2014 Qwen3-coder", "base_url": "http://localhost:11434/v1", + "api_key": "ollama", "model": "qwen3-coder"}, + ], + max_models=50, + ) + + custom = [p for p in providers if p.get("is_user_defined")] + assert len(custom) == 1, ( + f"expected one collapsed row, got {[p['name'] for p in custom]}" + ) + assert custom[0]["name"] == "Ollama" + assert set(custom[0]["models"]) == {"glm-5.1", "qwen3-coder"}