diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 6826476fdc..df3fdeccc6 100644 --- a/agent/auxiliary_client.py +++ b/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 resolve_provider_client( # ── 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 resolve_provider_client( creds = resolve_api_key_provider_credentials(provider) api_key = str(creds.get("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: tried_sources = list(pconfig.api_key_env_vars) if provider == "copilot": @@ -2284,6 +2307,11 @@ def resolve_provider_client( 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) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 3afd67e1cc..dfdc911569 100644 --- a/hermes_cli/runtime_provider.py +++ b/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() diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 79f8b2f7e7..52c85998e3 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -427,3 +427,68 @@ class TestProvidersDictApiModeAnthropicMessages: 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": "my-kimi-key", + "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="override-key", + ) + assert isinstance(client, OpenAI) + assert "override.example.com" in str(client.base_url) + assert client.api_key == "override-key" diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index c7adfe1482..d17b1a41e3 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/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": "my-kimi-key", + } + ] + }, + ) + + 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": "shadow-key", + } + ] + }, + ) + + 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