Custom providers sharing base_url using wrong credential pool keys
Cluster 9315 · agent. Data as of
1 open item. Fix merged in #21219 on 7 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 13 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #21219. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #21219. That is separate from the fix event. - Still open
- 1 issue and 0 PRs.
Next step: read #21219, then check whether your report is the one still open.
Read #21219Pick up a Lab taskNearly 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.
Open issues1
- Issue#9315[Bug]: Custom provider API key incorrectly overridden by base_url matching
Open PRs0
Fixes on record
-
#21219 fix(credential_pool): resolve key mix-up when custom providers share base_url (salvage #19094)
files:
agent/credential_pool.py,hermes_cli/runtime_provider.py,tests/agent/test_credential_pool.pyShow the diff
-
agent/credential_pool.py@@ -305,14 +305,29 @@ def _iter_custom_providers(config: Optional[dict] = None): yield _normalize_custom_pool_name(name), entry -def get_custom_provider_pool_key(base_url: str) -> Optional[str]: +def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]: """Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url. + When provider_name is given, prefer matching by name first (solving the case where + multiple custom providers share the same base_url but have different API keys). + Falls back to base_url matching when no name match is found. + Returns None if no match is found. """ if not base_url: return None normalized_url = base_url.strip().rstrip("/") + + # When a provider name is given, try to match by name first. + # This fixes the P1 bug where two custom providers sharing the same + # base_url always resolve to the first one's credentials. + if provider_name: + normalized_name = _normalize_custom_pool_name(provider_name) + for norm_name, entry in _iter_custom_providers(): + if norm_name == normalized_name: + return f"{CUSTOM_POOL_PREFIX}{norm_name}" + + # Fall back to base_url matching (original behavior) for norm_name, entry in _iter_custom_providers(): entry_url = str(entry.get("base_url") or "").strip().rstrip("/") if entry_url and entry_url == normalized_url: -
hermes_cli/runtime_provider.py@@ -319,9 +319,10 @@ def _try_resolve_from_custom_pool( base_url: str, provider_label: str, api_mode_override: Optional[str] = None, + provider_name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Check if a credential pool exists for a custom endpoint and return a runtime dict if so.""" - pool_key = get_custom_provider_pool_key(base_url) + pool_key = get_custom_provider_pool_key(base_url, provider_name=provider_name) if not pool_key: return None try: @@ -521,7 +522,7 @@ def _resolve_named_custom_runtime( return None # Check if a credential pool exists for this custom endpoint - pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode")) + pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"), provider_name=custom_provider.get("name")) if pool_result: # Propagate the model name even when using pooled credentials — # the pool doesn't know about the custom_providers model field. @@ -640,8 +641,11 @@ def _resolve_openrouter_runtime( # For custom endpoints, check if a credential pool exists if effective_provider == "custom" and base_url: + # Pass requested_provider so pool lookup prefers name match over base_url, + # fixing credential mix-ups when multiple custom providers share a base_url. pool_result = _try_resolve_from_custom_pool( base_url, effective_provider, _parse_api_mode(model_cfg.get("api_mode")), + provider_name=requested_provider if requested_norm != "custom" else None, ) if pool_result: return pool_result -
tests/agent/test_credential_pool.py@@ -924,6 +924,43 @@ def test_get_custom_provider_pool_key(tmp_path, monkeypatch): assert get_custom_provider_pool_key("") is None +def test_get_custom_provider_pool_key_prefers_name_over_base_url(tmp_path, monkeypatch): + """When two custom providers share the same base_url, provider_name resolves to the correct one.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + import yaml + config_path = tmp_path / "hermes" / "config.yaml" + config_path.write_text(yaml.dump({ + "custom_providers": [ + { + "name": "provider-a", + "base_url": "http://gateway:8080/v1", + "api_key": "[redacted:secret-assignment]", + }, + { + "name": "provider-b", + "base_url": "http://gateway:8080/v1", + "api_key": "[redacted:secret-assignment]", + }, + ] + })) + + from agent.credential_pool import get_custom_provider_pool_key + + # Without provider_name, first match wins (backward compatible) + assert get_custom_provider_pool_key("http://gateway:8080/v1") == "custom:provider-a" + + # With provider_name, exact name match wins regardless of order + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-b") == "custom:provider-b" + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-a") == "custom:provider-a" + + # Name match with non-matching base_url still works via fallback + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="nonexistent") == "custom:provider-a" + + # Empty provider_name is same as None (backward compatible) + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="") == "custom:provider-a" + + def test_list_custom_pool_providers(tmp_path, monkeypatch): """list_custom_pool_providers returns custom: pool keys from auth.json.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
-
9 PRs closed without merging
From the close comments: duplicate 4 · done elsewhere 3 · stale or abandoned 1 · superseded by another PR 1
- #9322 fix: explicit api_key should override pool credential for custom providers (#9315) · stale or abandoned
-
#9386 Fix custom provider API key precedence · superseded by another PR →
#21219 -
#10948 fix(runtime): preserve explicit custom API key precedence · duplicate →
#9386 -
#14142 Fix: Custom providers sharing same base_url use wrong API credentials · done elsewhere →
#14141 -
#14144 fix(runtime): keep named custom provider keys isolated · done elsewhere →
#14141
Show 4 more
- #14797 fix(cli): isolate custom provider credentials when providers share base_url · duplicate
-
#19094 fix(credential_pool): resolve key mix-up when custom providers share base_url · duplicate →
#14142,#18804 -
#19122 fix(agent): disambiguate credential pool key lookup when multiple providers share the same base_url (#19083) · duplicate →
#14142,#14141 -
#19682 fix(agent): resolve credential pool key ambiguity for shared base_url · done elsewhere →
e38ea38,#21219,#19083
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.15: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #21219
- Closed issues
- 2
- Attempts
- 9 PRs closed without merging
- Fix link checked
- 7 May 2026