Skip to content
Merged
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
9 changes: 8 additions & 1 deletion hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1501,7 +1501,14 @@ def _record_builtin_endpoint(slug: str) -> None:
current_base_url
and api_url == current_base_url.strip().rstrip("/")
):
slug = current_provider or custom_provider_slug(display_name)
# Guard against bare "custom" slug left by a prior
# failed switch — always resolve to the canonical
# custom:<name> form. (GH #17478)
slug = (
current_provider
if current_provider and current_provider != "custom"
else custom_provider_slug(display_name)
)
else:
slug = custom_provider_slug(display_name)
groups[group_key] = {
Expand Down
25 changes: 25 additions & 0 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,12 @@ def resolve_custom_provider(
if not requested:
return None

# If the stored provider is the bare string "custom" (corrupt state
# from a prior model-switch bug), fall back to the first custom
# provider entry so existing configs self-heal. (GH #17478)
bare_custom_fallback = requested == "custom"
first_valid = None

for entry in custom_providers:
if not isinstance(entry, dict):
continue
Expand All @@ -599,6 +605,10 @@ def resolve_custom_provider(
if not display_name or not api_url:
continue

# Stash the first valid entry for bare-"custom" fallback
if first_valid is None:
first_valid = (display_name, api_url)

slug = custom_provider_slug(display_name)
if requested not in {display_name.lower(), slug}:
continue
Expand All @@ -614,6 +624,21 @@ def resolve_custom_provider(
source="user-config",
)

# Self-heal: bare "custom" matched nothing — return first valid entry
if bare_custom_fallback and first_valid:
dname, aurl = first_valid
slug = custom_provider_slug(dname)
return ProviderDef(
id=slug,
name=dname,
transport="openai_chat",
api_key_env_vars=(),
base_url=aurl,
is_aggregator=False,
auth_type="api_key",
source="user-config",
)

return None


Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@
"hgk324@gmail.com": "houziershi",
"176644217+PStarH@users.noreply.github.com": "PStarH",
"51058514+Sanjays2402@users.noreply.github.com": "Sanjays2402",
"16577466+andy825@user.noreply.gitee.com": "Andy283",
"906014227@qq.com": "bingo906",
"aaronwong1999@icloud.com": "AaronWong1999",
"agents@kylefrench.dev": "DeployFaith",
Expand Down
33 changes: 30 additions & 3 deletions tests/hermes_cli/test_model_switch_custom_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,13 @@ def test_list_authenticated_providers_groups_same_endpoint(monkeypatch):
def test_list_authenticated_providers_current_endpoint_uses_current_slug(monkeypatch):
"""When current_base_url matches the grouped endpoint, the slug must
equal current_provider so picker selection routes through the live
credential pipeline."""
credential pipeline — provided current_provider is a real slug, not
the corrupt bare "custom" (see #17478)."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

providers = list_authenticated_providers(
current_provider="custom",
current_provider="custom:ollama",
current_base_url="http://localhost:11434/v1",
user_providers={},
custom_providers=[
Expand All @@ -314,10 +315,36 @@ def test_list_authenticated_providers_current_endpoint_uses_current_slug(monkeyp
matches = [p for p in providers if p.get("is_user_defined")]
assert len(matches) == 1
group = matches[0]
assert group["slug"] == "custom"
assert group["slug"] == "custom:ollama"
assert group["is_current"] is True


def test_list_authenticated_providers_bare_custom_slug_recovers(monkeypatch):
"""Regression for #17478: when a prior failed switch left the bare
literal "custom" in model.provider, the picker must NOT propagate
that broken slug. It must fall back to the canonical
``custom:<name>`` form so the picker stays usable."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

providers = list_authenticated_providers(
current_provider="custom",
current_base_url="http://localhost:11434/v1",
user_providers={},
custom_providers=[
{"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
"api_key": "ollama", "model": "glm-5.1"},
],
max_models=50,
)

matches = [p for p in providers if p.get("is_user_defined")]
assert len(matches) == 1
group = matches[0]
# Canonical slug, NOT the bare "custom" that caused #17478
assert group["slug"] == "custom:ollama"


def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypatch):
"""Entries with different base_urls must produce separate picker rows
even if some display names happen to be similar."""
Expand Down
Loading