Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This key still treats all key_env-backed entries as having an empty credential because api_key above only reads the inline field. More importantly, when salvaging onto current main, extend its existing credential_identity/api_mode/headers_identity key rather than replacing those discriminators with this triple.

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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions tests/hermes_cli/test_model_switch_custom_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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