{"repo":"NousResearch/hermes-agent","public_id":"9315","display_label":"Custom providers sharing base_url using wrong credential pool keys","summary":"Nearly all members concern custom providers that share a base_url receiving the wrong API key from the credential pool, including explicit api_key precedence and keeping named provider keys isolated. The group includes both issue reports and proposed fixes, and appears to center on one theme.","subsystem":"agent","counts":{"members":13,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21219,"merged_at":"2026-05-07T12:27:41+00:00"},"resolution":{"state":"partially_resolved","by":"pr-21219"},"freshness":{"state":"verified","last_verified_at":"2026-05-07T12:27:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15487179487179487},"fix_edges":[{"issue_number":9315,"pr_number":21219,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-07T12:27:41+00:00"},{"issue_number":9318,"pr_number":21219,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-07T12:27:41+00:00"},{"issue_number":19083,"pr_number":21219,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T12:27:41+00:00"}],"members":[{"kind":"issue","number":9315,"title":"[Bug]: Custom provider API key incorrectly overridden by base_url matching","url":null,"state":"open"},{"kind":"issue","number":14141,"title":"Custom providers sharing same base_url use wrong API credentials","url":null,"state":"closed"},{"kind":"issue","number":19083,"title":"Credential pool key mix-up when multiple custom providers share the same base_url","url":null,"state":"closed"},{"kind":"pr","number":9322,"title":"fix: explicit api_key should override pool credential for custom providers (#9315)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":9386,"title":"Fix custom provider API key precedence","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21219","number":21219}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":10948,"title":"fix(runtime): preserve explicit custom API key precedence","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9386","number":9386}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":14142,"title":"Fix: Custom providers sharing same base_url use wrong API credentials","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14141","number":14141}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":14144,"title":"fix(runtime): keep named custom provider keys isolated","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14141","number":14141}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14797,"title":"fix(cli): isolate custom provider credentials when providers share base_url","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":19094,"title":"fix(credential_pool): resolve key mix-up when custom providers share base_url","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14142","number":14142},{"kind":"pr","value":"#18804","number":18804}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":19122,"title":"fix(agent): disambiguate credential pool key lookup when multiple providers share the same base_url (#19083)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14142","number":14142},{"kind":"pr","value":"#14141","number":14141}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":19682,"title":"fix(agent): resolve credential pool key ambiguity for shared base_url","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e38ea38079b8683fba48a245c19ff5a2a8f50d39","number":0},{"kind":"pr","value":"#21219","number":21219},{"kind":"pr","value":"#19083","number":19083}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21219,"title":"fix(credential_pool): resolve key mix-up when custom providers share base_url (salvage #19094)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","hermes_cli/runtime_provider.py","tests/agent/test_credential_pool.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -305,14 +305,29 @@ def _iter_custom_providers(config: Optional[dict] = None):\n         yield _normalize_custom_pool_name(name), entry\n \n \n-def get_custom_provider_pool_key(base_url: str) -> Optional[str]:\n+def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]:\n     \"\"\"Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url.\n \n+    When provider_name is given, prefer matching by name first (solving the case where\n+    multiple custom providers share the same base_url but have different API keys).\n+    Falls back to base_url matching when no name match is found.\n+\n     Returns None if no match is found.\n     \"\"\"\n     if not base_url:\n         return None\n     normalized_url = base_url.strip().rstrip(\"/\")\n+\n+    # When a provider name is given, try to match by name first.\n+    # This fixes the P1 bug where two custom providers sharing the same\n+    # base_url always resolve to the first one's credentials.\n+    if provider_name:\n+        normalized_name = _normalize_custom_pool_name(provider_name)\n+        for norm_name, entry in _iter_custom_providers():\n+            if norm_name == normalized_name:\n+                return f\"{CUSTOM_POOL_PREFIX}{norm_name}\"\n+\n+    # Fall back to base_url matching (original behavior)\n     for norm_name, entry in _iter_custom_providers():\n         entry_url = str(entry.get(\"base_url\") or \"\").strip().rstrip(\"/\")\n         if entry_url and entry_url == normalized_url:"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -319,9 +319,10 @@ def _try_resolve_from_custom_pool(\n     base_url: str,\n     provider_label: str,\n     api_mode_override: Optional[str] = None,\n+    provider_name: Optional[str] = None,\n ) -> Optional[Dict[str, Any]]:\n     \"\"\"Check if a credential pool exists for a custom endpoint and return a runtime dict if so.\"\"\"\n-    pool_key = get_custom_provider_pool_key(base_url)\n+    pool_key = get_custom_provider_pool_key(base_url, provider_name=provider_name)\n     if not pool_key:\n         return None\n     try:","@@ -521,7 +522,7 @@ def _resolve_named_custom_runtime(\n         return None\n \n     # Check if a credential pool exists for this custom endpoint\n-    pool_result = _try_resolve_from_custom_pool(base_url, \"custom\", custom_provider.get(\"api_mode\"))\n+    pool_result = _try_resolve_from_custom_pool(base_url, \"custom\", custom_provider.get(\"api_mode\"), provider_name=custom_provider.get(\"name\"))\n     if pool_result:\n         # Propagate the model name even when using pooled credentials —\n         # the pool doesn't know about the custom_providers model field.","@@ -640,8 +641,11 @@ def _resolve_openrouter_runtime(\n \n     # For custom endpoints, check if a credential pool exists\n     if effective_provider == \"custom\" and base_url:\n+        # Pass requested_provider so pool lookup prefers name match over base_url,\n+        # fixing credential mix-ups when multiple custom providers share a base_url.\n         pool_result = _try_resolve_from_custom_pool(\n             base_url, effective_provider, _parse_api_mode(model_cfg.get(\"api_mode\")),\n+            provider_name=requested_provider if requested_norm != \"custom\" else None,\n         )\n         if pool_result:\n             return pool_result"]},{"file":"tests/agent/test_credential_pool.py","hunks":["@@ -924,6 +924,43 @@ def test_get_custom_provider_pool_key(tmp_path, monkeypatch):\n     assert get_custom_provider_pool_key(\"\") is None\n \n \n+def test_get_custom_provider_pool_key_prefers_name_over_base_url(tmp_path, monkeypatch):\n+    \"\"\"When two custom providers share the same base_url, provider_name resolves to the correct one.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    (tmp_path / \"hermes\").mkdir(parents=True, exist_ok=True)\n+    import yaml\n+    config_path = tmp_path / \"hermes\" / \"config.yaml\"\n+    config_path.write_text(yaml.dump({\n+        \"custom_providers\": [\n+            {\n+                \"name\": \"provider-a\",\n+                \"base_url\": \"http://gateway:8080/v1\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+            },\n+            {\n+                \"name\": \"provider-b\",\n+                \"base_url\": \"http://gateway:8080/v1\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+            },\n+        ]\n+    }))\n+\n+    from agent.credential_pool import get_custom_provider_pool_key\n+\n+    # Without provider_name, first match wins (backward compatible)\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\") == \"custom:provider-a\"\n+\n+    # With provider_name, exact name match wins regardless of order\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\", provider_name=\"provider-b\") == \"custom:provider-b\"\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\", provider_name=\"provider-a\") == \"custom:provider-a\"\n+\n+    # Name match with non-matching base_url still works via fallback\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\", provider_name=\"nonexistent\") == \"custom:provider-a\"\n+\n+    # Empty provider_name is same as None (backward compatible)\n+    assert get_custom_provider_pool_key(\"http://gateway:8080/v1\", provider_name=\"\") == \"custom:provider-a\"\n+\n+\n def test_list_custom_pool_providers(tmp_path, monkeypatch):\n     \"\"\"list_custom_pool_providers returns custom: pool keys from auth.json.\"\"\"\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}