fix(model-switch): keep same-endpoint custom providers with different names as separate picker rows

This commit is contained in:
Craig French 2026-07-19 18:19:17 -04:00 • committed by Teknium
parent 7ed18dae90
commit 1c3a48965b
2 changed files with 97 additions and 17 deletions

View file

@ -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,

View file

@ -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"}