{"repo":"NousResearch/hermes-agent","public_id":"17478","display_label":"Fixes for bare 'custom' slug written to model.provider","summary":"Multiple fixes and a bug report addressing an issue where the model picker writes the literal string 'custom' as the provider slug instead of the actual provider name. Changes span CLI, model-switch, and related resolution logic.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17891},"resolution":{"state":"resolved","by":"pr-17891"},"freshness":{"state":"verified","last_verified_at":"2026-04-30T11:32:12+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":17478,"pr_number":17891,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":17478,"title":"bug:  picker writes literal 'custom' to model.provider instead of actual provider name","url":null,"state":"closed"},{"kind":"pr","number":17481,"title":"fix: prevent bare 'custom' slug in model.provider (#17478)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"201f7caed8","number":0},{"kind":"commit","value":"61fec7689d","number":0},{"kind":"commit","value":"3fc4c63d38","number":0},{"kind":"pr","value":"#17891","number":17891}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17580,"title":"fix(cli): reject literal 'custom' as provider slug in model picker","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17481","number":17481}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":17671,"title":"fix(model-switch): skip \"custom\"/\"local\" sentinel when assigning picker slug (#17478)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17481","number":17481}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":17891,"title":"fix(model_switch): prevent bare 'custom' slug in model.provider (#17478)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/model_switch.py","hermes_cli/providers.py","scripts/release.py","tests/hermes_cli/test_model_switch_custom_providers.py"],"fix_hunks":[{"file":"hermes_cli/model_switch.py","hunks":["@@ -1501,7 +1501,14 @@ def _record_builtin_endpoint(slug: str) -> None:\n                     current_base_url\n                     and api_url == current_base_url.strip().rstrip(\"/\")\n                 ):\n-                    slug = current_provider or custom_provider_slug(display_name)\n+                    # Guard against bare \"custom\" slug left by a prior\n+                    # failed switch — always resolve to the canonical\n+                    # custom:<name> form.  (GH #17478)\n+                    slug = (\n+                        current_provider\n+                        if current_provider and current_provider != \"custom\"\n+                        else custom_provider_slug(display_name)\n+                    )\n                 else:\n                     slug = custom_provider_slug(display_name)\n                 groups[group_key] = {"]},{"file":"hermes_cli/providers.py","hunks":["@@ -585,6 +585,12 @@ def resolve_custom_provider(\n     if not requested:\n         return None\n \n+    # If the stored provider is the bare string \"custom\" (corrupt state\n+    # from a prior model-switch bug), fall back to the first custom\n+    # provider entry so existing configs self-heal.  (GH #17478)\n+    bare_custom_fallback = requested == \"custom\"\n+    first_valid = None\n+\n     for entry in custom_providers:\n         if not isinstance(entry, dict):\n             continue","@@ -599,6 +605,10 @@ def resolve_custom_provider(\n         if not display_name or not api_url:\n             continue\n \n+        # Stash the first valid entry for bare-\"custom\" fallback\n+        if first_valid is None:\n+            first_valid = (display_name, api_url)\n+\n         slug = custom_provider_slug(display_name)\n         if requested not in {display_name.lower(), slug}:\n             continue","@@ -614,6 +624,21 @@ def resolve_custom_provider(\n             source=\"user-config\",\n         )\n \n+    # Self-heal: bare \"custom\" matched nothing — return first valid entry\n+    if bare_custom_fallback and first_valid:\n+        dname, aurl = first_valid\n+        slug = custom_provider_slug(dname)\n+        return ProviderDef(\n+            id=slug,\n+            name=dname,\n+            transport=\"openai_chat\",\n+            api_key_env_vars=(),\n+            base_url=aurl,\n+            is_aggregator=False,\n+            auth_type=\"api_key\",\n+            source=\"user-config\",\n+        )\n+\n     return None\n \n "]},{"file":"scripts/release.py","hunks":["@@ -349,6 +349,7 @@\n     \"[redacted:email]\": \"houziershi\",\n     \"[redacted:email]\": \"PStarH\",\n     \"[redacted:email]\": \"Sanjays2402\",\n+    \"[redacted:email]\": \"Andy283\",\n     \"[redacted:email]\": \"bingo906\",\n     \"[redacted:email]\": \"AaronWong1999\",\n     \"[redacted:email]\": \"DeployFaith\","]},{"file":"tests/hermes_cli/test_model_switch_custom_providers.py","hunks":["@@ -296,12 +296,13 @@ def test_list_authenticated_providers_groups_same_endpoint(monkeypatch):\n def test_list_authenticated_providers_current_endpoint_uses_current_slug(monkeypatch):\n     \"\"\"When current_base_url matches the grouped endpoint, the slug must\n     equal current_provider so picker selection routes through the live\n-    credential pipeline.\"\"\"\n+    credential pipeline — provided current_provider is a real slug, not\n+    the corrupt bare \"custom\" (see #17478).\"\"\"\n     monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n     monkeypatch.setattr(providers_mod, \"HERMES_OVERLAYS\", {})\n \n     providers = list_authenticated_providers(\n-        current_provider=\"custom\",\n+        current_provider=\"custom:ollama\",\n         current_base_url=\"http://localhost:11434/v1\",\n         user_providers={},\n         custom_providers=[","@@ -314,10 +315,36 @@ def test_list_authenticated_providers_current_endpoint_uses_current_slug(monkeyp\n     matches = [p for p in providers if p.get(\"is_user_defined\")]\n     assert len(matches) == 1\n     group = matches[0]\n-    assert group[\"slug\"] == \"custom\"\n+    assert group[\"slug\"] == \"custom:ollama\"\n     assert group[\"is_current\"] is True\n \n \n+def test_list_authenticated_providers_bare_custom_slug_recovers(monkeypatch):\n+    \"\"\"Regression for #17478: when a prior failed switch left the bare\n+    literal \"custom\" in model.provider, the picker must NOT propagate\n+    that broken slug. It must fall back to the canonical\n+    ``custom:<name>`` form so the picker stays usable.\"\"\"\n+    monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n+    monkeypatch.setattr(providers_mod, \"HERMES_OVERLAYS\", {})\n+\n+    providers = list_authenticated_providers(\n+        current_provider=\"custom\",\n+        current_base_url=\"http://localhost:11434/v1\",\n+        user_providers={},\n+        custom_providers=[\n+            {\"name\": \"Ollama — GLM 5.1\", \"base_url\": \"http://localhost:11434/v1\",\n+             \"api_key\": \"ollama\", \"model\": \"glm-5.1\"},\n+        ],\n+        max_models=50,\n+    )\n+\n+    matches = [p for p in providers if p.get(\"is_user_defined\")]\n+    assert len(matches) == 1\n+    group = matches[0]\n+    # Canonical slug, NOT the bare \"custom\" that caused #17478\n+    assert group[\"slug\"] == \"custom:ollama\"\n+\n+\n def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypatch):\n     \"\"\"Entries with different base_urls must produce separate picker rows\n     even if some display names happen to be similar.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}