Fallback provider base_url resolution from custom_providers
Cluster 15743. Data as of
Nothing open on GitHub. Fix merged in #18185 on 1 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #18185. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #18185. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members center on fallback providers defined in custom_providers, where one issue reports the request going to the primary provider's endpoint instead of the fallback's own base_url. The related pull requests propose looking up base_url from custom_providers and letting custom_providers shadow built-in aliases.
Open issues0
Open PRs0
Fixes on record
-
#18185 fix(fallback): let custom_providers shadow built-in aliases
files:
agent/auxiliary_client.py,hermes_cli/runtime_provider.py,tests/agent/test_auxiliary_named_custom_providers.py,tests/hermes_cli/test_runtime_provider_resolution.pyShow the diff
-
agent/auxiliary_client.py@@ -1977,6 +1977,12 @@ def resolve_provider_client( (client, resolved_model) or (None, None) if auth is unavailable. """ _validate_proxy_env_urls() + # Preserve the original provider name before alias normalization so a + # user-declared ``custom_providers`` entry whose name coincidentally + # matches a built-in alias (e.g. user names their custom provider "kimi" + # which aliases to "kimi-coding") is still reachable via the named-custom + # branch below. + original_provider = (provider or "").strip().lower() # Normalise aliases provider = _normalize_aux_provider(provider) @@ -2163,7 +2169,18 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", # ── Named custom providers (config.yaml providers dict / custom_providers list) ─── try: from hermes_cli.runtime_provider import _get_named_custom_provider - custom_entry = _get_named_custom_provider(provider) + # When the raw requested name is an alias (``kimi`` → ``kimi-coding``) + # and the user defined a ``custom_providers`` entry under that alias + # name, the custom entry is the intended target — the built-in alias + # rewriting would otherwise hijack the request. Only preferred when + # the raw name is an alias (not a canonical provider name) so custom + # entries that coincidentally match a canonical provider (e.g. ``nous``) + # still defer to the built-in per `_get_named_custom_provider`'s guard. + custom_entry = None + if original_provider and original_provider != provider: + custom_entry = _get_named_custom_provider(original_provider) + if custom_entry is None: + custom_entry = _get_named_custom_provider(provider) if custom_entry: custom_base = custom_entry.get("base_url", "").strip() custom_key = custom_entry.get("api_key", "").strip() @@ -2273,6 +2290,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", creds = resolve_api_key_provider_credentials(provider) api_key = [redacted:secret-assignment]"api_key", "")).strip() + # Honour an explicit api_key override (e.g. from a fallback_model entry + # or a custom_providers entry) so callers that pass an explicit + # credential can authenticate against endpoints where no built-in + # credential is registered for this provider alias. + if explicit_api_key: + api_key = explicit_api_key.strip() or api_key if not api_key: [redacted:secret-assignment] = list(pconfig.api_key_env_vars) if provider == "copilot": @@ -2284,6 +2307,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url base_url = _to_openai_base_url(raw_base_url) + # Honour an explicit base_url override from the caller — used when a + # fallback_model entry (or custom_providers lookup) routes through a + # built-in provider name but targets a user-specified endpoint. + if explicit_base_url: + base_url = _to_openai_base_url(explicit_base_url.strip().rstrip("/")) default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "") final_model = _normalize_resolved_model(model or default_model, provider) -
hermes_cli/runtime_provider.py@@ -358,11 +358,20 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An return None if not requested_norm.startswith("custom:"): try: - auth_mod.resolve_provider(requested_norm) + canonical = auth_mod.resolve_provider(requested_norm) except AuthError: pass else: - return None + # A user-declared ``custom_providers`` entry whose name matches + # only an *alias* (``kimi`` → built-in ``kimi-coding``) is the + # user's intended target — alias rewriting would otherwise hijack + # the request. We only defer to the built-in when the raw name is + # the canonical provider itself (``nous``, ``openrouter``, …) so + # accidentally shadowing a canonical provider still resolves to + # the built-in. See tests/hermes_cli/test_runtime_provider_resolution.py + # ``test_named_custom_provider_does_not_shadow_builtin_provider``. + if (canonical or "").strip().lower() == requested_norm: + return None config = load_config() -
tests/agent/test_auxiliary_named_custom_providers.py@@ -427,3 +427,68 @@ def test_provider_without_api_mode_still_uses_openai(self, tmp_path): assert isinstance(sync_client, OpenAI) async_client, _ = resolve_provider_client("localchat", async_mode=True) assert isinstance(async_client, AsyncOpenAI) + + +class TestCustomProviderAliasCollision: + """A user-declared custom_providers entry whose name matches a built-in + *alias* (not a canonical provider) must win over the built-in. + + Regression guard for #15743: users who defined fallback_model pointing at + a custom_providers entry named ``kimi`` were having requests routed to + the built-in kimi-coding endpoint because ``_normalize_aux_provider`` + rewrote ``kimi`` → ``kimi-coding`` before the named-custom lookup. + """ + + def test_custom_named_kimi_wins_over_builtin_alias(self, tmp_path): + _write_config(tmp_path, { + "model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}, + "custom_providers": [ + { + "name": "kimi", + "base_url": "https://my-custom-kimi.example.com/v1", + "api_key": "[redacted:secret-assignment]", + "models": {"my-kimi-model": {"context_length": 200000}}, + }, + ], + }) + from agent.auxiliary_client import resolve_provider_client + from openai import OpenAI + client, model = resolve_provider_client("kimi", model="my-kimi-model", raw_codex=True) + assert isinstance(client, OpenAI) + assert "my-custom-kimi.example.com" in str(client.base_url) + assert client.api_key == "my-kimi-key" + assert model == "my-kimi-model" + + def test_bare_kimi_without_custom_still_routes_to_builtin(self, tmp_path, monkeypatch): + """Regression guard: bare 'kimi' with no custom entry must still + reach the built-in kimi-coding provider.""" + _write_config(tmp_path, { + "model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}, + }) + monkeypatch.setenv("KIMI_API_KEY", "builtin-kimi-key") + from agent.auxiliary_client import resolve_provider_client + client, _ = resolve_provider_client("kimi", model="kimi-k2-0905-preview", raw_codex=True) + assert client is not None + base_url = str(client.base_url) + # Built-in kimi-coding points at api.moonshot.ai + assert "moonshot" in base_url or "kimi" in base_url, f"unexpected base_url {base_url!r}" + + def test_explicit_overrides_applied_on_api_key_branch(self, tmp_path, monkeypatch): + """Explicit base_url/api_key from the caller must override the + registered provider's defaults on the API-key branch. Used by + _try_activate_fallback to route a fallback through a built-in + provider name but targeting a user-supplied endpoint.""" + _write_config(tmp_path, { + "model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"}, + }) + monkeypatch.setenv("KIMI_API_KEY", "builtin-kimi-key") + from agent.auxiliary_client import resolve_provider_client + from openai import OpenAI + client, _ = resolve_provider_client( + "kimi-coding", model="kimi-k2", raw_codex=True, + explicit_base_url="https://override.example.com", + explicit_api_key="[redacted:secret-assignment]", + ) + assert isinstance(client, OpenAI) + assert "override.example.com" in str(client.base_url) + assert client.api_key == "override-key" -
tests/hermes_cli/test_runtime_provider_resolution.py@@ -897,6 +897,58 @@ def test_named_custom_provider_does_not_shadow_builtin_provider(monkeypatch): assert resolved["requested_provider"] == "nous" +def test_named_custom_provider_wins_over_builtin_alias(monkeypatch): + """A custom_providers entry named after a built-in *alias* (not a canonical + provider name) must win over the built-in. Regression guard for #15743: + when users define ``custom_providers: [{name: kimi, ...}]`` and reference + ``provider: kimi``, the built-in alias rewriting (``kimi`` → ``kimi-coding``) + would otherwise hijack the request and send it to the wrong endpoint. + """ + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + { + "name": "kimi", + "base_url": "https://my-custom-kimi.example.com/v1", + "api_key": "[redacted:secret-assignment]", + } + ] + }, + ) + + entry = rp._get_named_custom_provider("kimi") + + assert entry is not None + assert entry["base_url"] == "https://my-custom-kimi.example.com/v1" + assert entry["api_key"] == "my-kimi-key" + + +def test_named_custom_provider_skipped_for_canonical_built_in(monkeypatch): + """Companion to the test above: ``nous`` is a canonical provider name + (``resolve_provider('nous') == 'nous'``), so a custom entry with that name + should NOT be returned — the built-in wins as before. + """ + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + { + "name": "nous", + "base_url": "http://localhost:1234/v1", + "api_key": "[redacted:secret-assignment]", + } + ] + }, + ) + + entry = rp._get_named_custom_provider("nous") + + assert entry is None + + def test_explicit_openrouter_skips_openai_base_url(monkeypatch): """When the user explicitly requests openrouter, OPENAI_BASE_URL (which may point to a custom endpoint) must not override the
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1
Closed issues · 1
- Closed#15743Fallback provider defined in custom_providers ignores its own base_url, sends request to primary provider's endpoint instead
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: 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
- Merged PRs
- #18185
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 1 May 2026