{"repo":"NousResearch/hermes-agent","public_id":"34725","display_label":"Custom provider credential isolation in model picker","summary":"A bug where the model picker merges custom providers sharing the same base URL but different credentials, causing routing through wrong credentials. Two fix attempts address isolating custom provider picker credentials.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34810,"merged_at":"2026-05-29T19:32:35+00:00"},"resolution":{"state":"resolved","by":"pr-34810"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T19:32:35+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":34725,"pr_number":34810,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T19:32:35+00:00"}],"members":[{"kind":"issue","number":34725,"title":"/model picker merges custom_providers with same base_url but different key_env/api_mode, routes through wrong credentials","url":null,"state":"closed"},{"kind":"pr","number":34757,"title":"fix(model): isolate custom provider picker credentials","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34810","number":34810},{"kind":"pr","value":"#34725","number":34725}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34810,"title":"fix(model): isolate custom provider picker credentials (salvage #34757)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/model_switch.py","scripts/release.py","tests/hermes_cli/test_model_switch_custom_providers.py","tests/hermes_cli/test_runtime_provider_resolution.py"],"fix_hunks":[{"file":"hermes_cli/model_switch.py","hunks":["@@ -1556,24 +1556,21 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n \n     # --- 4. Saved custom providers from config ---\n     # Each ``custom_providers`` entry represents one model under a named\n-    # provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)\n-    # are grouped into a single picker row, so e.g. four Ollama entries\n-    # pointing at ``http://localhost:11434/v1`` with per-model display names\n-    # (\"Ollama — GLM 5.1\", \"Ollama — Qwen3-coder\", ...) appear as one\n+    # provider. Entries sharing the same endpoint, credential identity, and\n+    # wire protocol are grouped into a single picker row, so e.g. four Ollama\n+    # entries pointing at ``http://localhost:11434/v1`` with per-model display\n+    # names (\"Ollama — GLM 5.1\", \"Ollama — Qwen3-coder\", ...) appear as one\n     # \"Ollama\" row with four models inside instead of four near-duplicates\n-    # that differ only by suffix. Entries with distinct endpoints still\n-    # produce separate rows.\n-    #\n-    # When the grouped endpoint matches ``current_base_url`` the group's\n-    # slug becomes ``current_provider`` so that selecting a model from the\n-    # picker flows back through the runtime provider that already holds\n-    # valid credentials — no re-resolution needed.\n+    # that differ only by suffix. Same-host entries with different ``key_env``\n+    # or ``api_mode`` remain distinct providers.\n     if custom_providers and isinstance(custom_providers, list):\n         from collections import OrderedDict\n \n-        # Key by (base_url, api_key) instead of slug: names frequently\n-        # differ per model (\"Ollama — X\") while the endpoint stays the\n-        # same. Slug-based grouping left them as separate rows.\n+        # Key by endpoint + credential identity + wire protocol instead of\n+        # slug: names frequently differ per model (\"Ollama — X\") while the\n+        # endpoint stays the same.  Keep same-host providers with distinct\n+        # env-backed credentials or API protocols separate so picker selection\n+        # cannot route through the wrong credential/mode pair.\n         groups: \"OrderedDict[tuple, dict]\" = OrderedDict()\n         for entry in custom_providers:\n             if not isinstance(entry, dict):","@@ -1588,9 +1585,23 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n             ).strip().rstrip(\"/\")\n             if not raw_name or not api_url:\n                 continue\n-            api_key = [redacted:secret-assignment]\"api_key\") or \"\").strip()\n+            inline_api_key = [redacted:secret-assignment]\"api_key\") or \"\").strip()\n+            key_env = (entry.get(\"key_env\") or \"\").strip()\n+            api_key = [redacted:secret-assignment] or (\n+                os.environ.get(key_env, \"\").strip() if key_env else \"\"\n+            )\n+            api_mode = str(\n+                entry.get(\"api_mode\")\n+                or entry.get(\"transport\")\n+                or \"\"\n+            ).strip().lower()\n+            credential_identity = (\n+                inline_api_key\n+                if inline_api_key\n+                else (f\"env:{key_env}\" if key_env else \"\")\n+            )\n \n-            group_key = (api_url, api_key)\n+            group_key = (api_url, credential_identity, api_mode)\n             if group_key not in groups:\n                 # Strip per-model suffix so \"Ollama — GLM 5.1\" becomes\n                 # \"Ollama\" for the grouped row. Em dash is the convention","@@ -1603,29 +1614,16 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n                         break\n                 if not display_name:\n                     display_name = raw_name\n-                # If this endpoint matches the currently active one, use\n-                # ``current_provider`` as the slug so picker-driven switches\n-                # route through the live credential pipeline.\n-                if (\n-                    current_base_url\n-                    and api_url == current_base_url.strip().rstrip(\"/\")\n-                ):\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+                slug = custom_provider_slug(display_name)\n                 groups[group_key] = {\n                     \"slug\": slug,\n                     \"name\": display_name,\n                     \"api_url\": api_url,\n+                    \"api_key\": [redacted:secret-assignment]\n                     \"models\": [],\n                 }\n+            elif api_key and not groups[group_key].get(\"api_key\"):\n+                groups[group_key][\"api_key\"] = api_key\n \n             # The singular ``model:`` field only holds the currently\n             # active model. Hermes's own writer (main.py::_save_custom_provider)","@@ -1647,8 +1645,16 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n                         groups[group_key][\"models\"].append(m)\n \n         _section4_emitted_slugs: set = set()\n-        for grp_key, grp in groups.items():\n-            api_url, api_key = [redacted:secret-assignment]\n+        _current_base_url_norm = str(current_base_url or \"\").strip().rstrip(\"/\").lower()\n+        _current_base_url_group_count = sum(\n+            1\n+            for _grp in groups.values()\n+            if _current_base_url_norm\n+            and str(_grp[\"api_url\"]).strip().rstrip(\"/\").lower() == _current_base_url_norm\n+        )\n+        for grp in groups.values():\n+            api_url = grp[\"api_url\"]\n+            api_key = grp.get(\"api_key\", \"\")\n             slug = grp[\"slug\"]\n             # If the slug is already claimed by a built-in / overlay /\n             # user-provider row (sections 1-3), skip this custom group","@@ -1721,8 +1727,10 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n                 \"slug\": slug,\n                 \"name\": grp[\"name\"],\n                 \"is_current\": slug == current_provider or (\n-                    bool(current_base_url)\n-                    and _grp_url_norm == current_base_url.strip().rstrip(\"/\").lower()\n+                    current_provider == \"custom\"\n+                    and bool(_current_base_url_norm)\n+                    and _grp_url_norm == _current_base_url_norm\n+                    and _current_base_url_group_count == 1\n                 ),\n                 \"is_user_defined\": True,\n                 \"models\": grp[\"models\"],"]},{"file":"scripts/release.py","hunks":["@@ -112,6 +112,7 @@\n     \"[redacted:email]\": \"devwdave\",\n     \"[redacted:email]\": \"devwdave\",\n     \"[redacted:email]\": \"zapabob\",\n+    \"zapabob@users.noreply.github.com\": \"zapabob\",\n     \"[redacted:email]\": \"jfuenmayor\",\n     \"jiahuigu@users.noreply.github.com\": \"Jiahui-Gu\",\n     \"[redacted:email]\": \"YLChen-007\","]},{"file":"tests/hermes_cli/test_model_switch_custom_providers.py","hunks":["@@ -403,6 +403,44 @@ def test_list_authenticated_providers_same_url_different_keys_disambiguated(monk\n     assert models[\"custom:openai-2\"] == [\"gpt-4.6\"]\n \n \n+def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):\n+    \"\"\"Same gateway host but different key_env/api_mode entries are distinct providers.\"\"\"\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:gpt\",\n+        current_base_url=\"https://gateway.example.com\",\n+        user_providers={},\n+        custom_providers=[\n+            {\n+                \"name\": \"gpt\",\n+                \"base_url\": \"https://gateway.example.com\",\n+                \"key_env\": \"GPT_KEY\",\n+                \"api_mode\": \"codex_responses\",\n+                \"model\": \"gpt-5.5\",\n+            },\n+            {\n+                \"name\": \"claude\",\n+                \"base_url\": \"https://gateway.example.com\",\n+                \"key_env\": \"CLAUDE_KEY\",\n+                \"api_mode\": \"anthropic_messages\",\n+                \"model\": \"claude-opus-4-8\",\n+            },\n+        ],\n+        max_models=50,\n+    )\n+\n+    custom = [p for p in providers if p.get(\"is_user_defined\")]\n+    by_slug = {p[\"slug\"]: p for p in custom}\n+\n+    assert set(by_slug) == {\"custom:gpt\", \"custom:claude\"}\n+    assert by_slug[\"custom:gpt\"][\"models\"] == [\"gpt-5.5\"]\n+    assert by_slug[\"custom:claude\"][\"models\"] == [\"claude-opus-4-8\"]\n+    assert by_slug[\"custom:gpt\"][\"is_current\"] is True\n+    assert by_slug[\"custom:claude\"][\"is_current\"] is False\n+\n+\n def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):\n     \"\"\"After grouping six entries into one row, total_models must reflect\n     the full count, and every grouped model appears in the list.\"\"\""]},{"file":"tests/hermes_cli/test_runtime_provider_resolution.py","hunks":["@@ -793,6 +793,54 @@ def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch):\n     assert resolved[\"model\"] == \"acme-large\"\n \n \n+def test_named_custom_provider_same_url_uses_matching_key_env_and_api_mode(monkeypatch):\n+    \"\"\"Named custom providers on one gateway must keep their own credentials and protocol.\"\"\"\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+    monkeypatch.setenv(\"GPT_KEY\", \"gpt-secret\")\n+    monkeypatch.setenv(\"CLAUDE_KEY\", \"claude-secret\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\n+                    \"name\": \"gpt\",\n+                    \"base_url\": \"https://gateway.example.com\",\n+                    \"key_env\": \"GPT_KEY\",\n+                    \"api_mode\": \"codex_responses\",\n+                    \"model\": \"gpt-5.5\",\n+                },\n+                {\n+                    \"name\": \"claude\",\n+                    \"base_url\": \"https://gateway.example.com\",\n+                    \"key_env\": \"CLAUDE_KEY\",\n+                    \"api_mode\": \"anthropic_messages\",\n+                    \"model\": \"claude-opus-4-8\",\n+                },\n+            ],\n+        },\n+    )\n+    monkeypatch.setattr(\n+        rp,\n+        \"resolve_provider\",\n+        lambda *a, **k: (_ for _ in ()).throw(\n+            AssertionError(\n+                \"resolve_provider should not be called for named custom providers\"\n+            )\n+        ),\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"custom:claude\")\n+\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert resolved[\"base_url\"] == \"https://gateway.example.com\"\n+    assert resolved[\"api_key\"] == \"claude-secret\"\n+    assert resolved[\"api_mode\"] == \"anthropic_messages\"\n+    assert resolved[\"requested_provider\"] == \"custom:claude\"\n+    assert resolved[\"model\"] == \"claude-opus-4-8\"\n+\n+\n def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):\n     monkeypatch.setenv(\"OPENAI_API_KEY\", \"env-openai-key\")\n     monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}