diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index dcdd81df4a796..268312c35243c 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1561,9 +1561,13 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if custom_providers and isinstance(custom_providers, list): from collections import OrderedDict - # Key by (base_url, api_key) instead of slug: names frequently - # differ per model ("Ollama — X") while the endpoint stays the - # same. Slug-based grouping left them as separate rows. + # Key by (base_url, api_key, cleaned_name) so that entries sharing + # the same endpoint AND the same base provider name collapse into + # one picker row (e.g. "Ollama — GLM 5.1" + "Ollama — Qwen3" + # become a single "Ollama" row), while entries with distinct names + # remain separate even when they share a proxy endpoint (e.g. + # "guava-litellm", "copilot-litellm", "elderberry-mtp" all routing + # through the same LiteLLM proxy stay as separate picker rows). groups: "OrderedDict[tuple, dict]" = OrderedDict() for entry in custom_providers: if not isinstance(entry, dict): @@ -1580,7 +1584,16 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: continue api_key = (entry.get("api_key") or "").strip() - group_key = (api_url, api_key) + # Clean the name for grouping (strip per-model em-dash suffix) + # BEFORE using it as the group key, so "Ollama — X" entries + # still collapse together. + _cleaned_name = raw_name + for _sep in ("—", " - "): + if _sep in _cleaned_name: + _cleaned_name = _cleaned_name.split(_sep)[0].strip() + break + + group_key = (api_url, api_key, _cleaned_name) 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 @@ -1638,7 +1651,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: _section4_emitted_slugs: set = set() for grp_key, grp in groups.items(): - api_url, api_key = grp_key + api_url, api_key, _grp_name = grp_key slug = grp["slug"] # If the slug is already claimed by a built-in / overlay / # user-provider row (sections 1-3), skip this custom group diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 84734e622d5f4..e391e6a28cb37 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -567,3 +567,64 @@ def fake_fetch_api_models(api_key, base_url): "gateway-model-c", ], "Live models must replace the static subset" assert gateway_prov["total_models"] == 3 + + +def test_list_same_endpoint_distinct_names_stay_separate(monkeypatch): + """Entries sharing a base_url+api_key but with distinct provider names + must produce separate picker rows. + + Real-world case: a LiteLLM proxy routes bedrock, copilot, and local + models through a single endpoint. Each provider group should remain + individually selectable in the picker, not collapsed into one giant row. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + providers = list_authenticated_providers( + current_provider="custom:guava-litellm", + current_base_url="http://192.168.68.79:8182/v1", + user_providers={}, + custom_providers=[ + {"name": "guava-litellm", "base_url": "http://192.168.68.79:8182/v1", + "api_key": "sk-proxy", "model": "bedrock/claude-haiku-4.5"}, + {"name": "guava-litellm", "base_url": "http://192.168.68.79:8182/v1", + "api_key": "sk-proxy", "model": "bedrock/claude-sonnet-4.6"}, + {"name": "guava-litellm", "base_url": "http://192.168.68.79:8182/v1", + "api_key": "sk-proxy", "model": "bedrock/claude-opus-4.6"}, + {"name": "copilot-litellm", "base_url": "http://192.168.68.79:8182/v1", + "api_key": "sk-proxy", "model": "copilot/gpt-5.2"}, + {"name": "copilot-litellm", "base_url": "http://192.168.68.79:8182/v1", + "api_key": "sk-proxy", "model": "copilot/gpt-4.1"}, + {"name": "elderberry-mtp", "base_url": "http://192.168.68.79:8182/v1", + "api_key": "sk-proxy", "model": "mtp/default"}, + {"name": "elderberry-ollama", "base_url": "http://192.168.68.79:8182/v1", + "api_key": "sk-proxy", "model": "ollama/qwen3.5:9b"}, + {"name": "elderberry-ollama", "base_url": "http://192.168.68.79:8182/v1", + "api_key": "sk-proxy", "model": "ollama/qwen3.6:35b"}, + ], + max_models=50, + ) + + custom_groups = [p for p in providers if p.get("is_user_defined")] + assert len(custom_groups) == 4, ( + f"Expected 4 provider groups, got {len(custom_groups)}: " + f"{[p['name'] for p in custom_groups]}" + ) + + guava = next(p for p in custom_groups if p["name"] == "guava-litellm") + assert set(guava["models"]) == { + "bedrock/claude-haiku-4.5", "bedrock/claude-sonnet-4.6", "bedrock/claude-opus-4.6" + } + assert guava["total_models"] == 3 + + copilot = next(p for p in custom_groups if p["name"] == "copilot-litellm") + assert set(copilot["models"]) == {"copilot/gpt-5.2", "copilot/gpt-4.1"} + assert copilot["total_models"] == 2 + + mtp = next(p for p in custom_groups if p["name"] == "elderberry-mtp") + assert mtp["models"] == ["mtp/default"] + assert mtp["total_models"] == 1 + + ollama = next(p for p in custom_groups if p["name"] == "elderberry-ollama") + assert set(ollama["models"]) == {"ollama/qwen3.5:9b", "ollama/qwen3.6:35b"} + assert ollama["total_models"] == 2