{"repo":"NousResearch/hermes-agent","public_id":"15743","display_label":"Fallback provider base_url resolution from custom_providers","summary":"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.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":18185,"merged_at":"2026-05-01T03:18:45+00:00"},"resolution":{"state":"resolved","by":"pr-18185"},"freshness":{"state":"verified","last_verified_at":"2026-05-01T03:18:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":15743,"pr_number":18185,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-01T03:18:45+00:00"}],"members":[{"kind":"issue","number":15743,"title":"Fallback provider defined in custom_providers ignores its own base_url, sends request to primary provider's endpoint instead","url":null,"state":"closed"},{"kind":"pr","number":15756,"title":"fix(fallback): resolve base_url from custom_providers when fallback entry has none (#15743)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6ddc48b05","number":0},{"kind":"pr","value":"#15743","number":15743},{"kind":"pr","value":"#22780","number":22780},{"kind":"pr","value":"#22842","number":22842},{"kind":"pr","value":"#21219","number":21219}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":17827,"title":"fix(fallback): look up custom_providers base_url before fallback API call","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0ddc8aba6826c316060ff72f571f14bbba7058a8","number":0},{"kind":"pr","value":"#18185","number":18185}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":18185,"title":"fix(fallback): let custom_providers shadow built-in aliases","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","hermes_cli/runtime_provider.py","tests/agent/test_auxiliary_named_custom_providers.py","tests/hermes_cli/test_runtime_provider_resolution.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1977,6 +1977,12 @@ def resolve_provider_client(\n         (client, resolved_model) or (None, None) if auth is unavailable.\n     \"\"\"\n     _validate_proxy_env_urls()\n+    # Preserve the original provider name before alias normalization so a\n+    # user-declared ``custom_providers`` entry whose name coincidentally\n+    # matches a built-in alias (e.g. user names their custom provider \"kimi\"\n+    # which aliases to \"kimi-coding\") is still reachable via the named-custom\n+    # branch below.\n+    original_provider = (provider or \"\").strip().lower()\n     # Normalise aliases\n     provider = _normalize_aux_provider(provider)\n ","@@ -2163,7 +2169,18 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n     # ── Named custom providers (config.yaml providers dict / custom_providers list) ───\n     try:\n         from hermes_cli.runtime_provider import _get_named_custom_provider\n-        custom_entry = _get_named_custom_provider(provider)\n+        # When the raw requested name is an alias (``kimi`` → ``kimi-coding``)\n+        # and the user defined a ``custom_providers`` entry under that alias\n+        # name, the custom entry is the intended target — the built-in alias\n+        # rewriting would otherwise hijack the request.  Only preferred when\n+        # the raw name is an alias (not a canonical provider name) so custom\n+        # entries that coincidentally match a canonical provider (e.g. ``nous``)\n+        # still defer to the built-in per `_get_named_custom_provider`'s guard.\n+        custom_entry = None\n+        if original_provider and original_provider != provider:\n+            custom_entry = _get_named_custom_provider(original_provider)\n+        if custom_entry is None:\n+            custom_entry = _get_named_custom_provider(provider)\n         if custom_entry:\n             custom_base = custom_entry.get(\"base_url\", \"\").strip()\n             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 = \"\",\n \n         creds = resolve_api_key_provider_credentials(provider)\n         api_key = [redacted:secret-assignment]\"api_key\", \"\")).strip()\n+        # Honour an explicit api_key override (e.g. from a fallback_model entry\n+        # or a custom_providers entry) so callers that pass an explicit\n+        # credential can authenticate against endpoints where no built-in\n+        # credential is registered for this provider alias.\n+        if explicit_api_key:\n+            api_key = explicit_api_key.strip() or api_key\n         if not api_key:\n             [redacted:secret-assignment] = list(pconfig.api_key_env_vars)\n             if provider == \"copilot\":","@@ -2284,6 +2307,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n \n         raw_base_url = str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n         base_url = _to_openai_base_url(raw_base_url)\n+        # Honour an explicit base_url override from the caller — used when a\n+        # fallback_model entry (or custom_providers lookup) routes through a\n+        # built-in provider name but targets a user-specified endpoint.\n+        if explicit_base_url:\n+            base_url = _to_openai_base_url(explicit_base_url.strip().rstrip(\"/\"))\n \n         default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, \"\")\n         final_model = _normalize_resolved_model(model or default_model, provider)"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -358,11 +358,20 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n         return None\n     if not requested_norm.startswith(\"custom:\"):\n         try:\n-            auth_mod.resolve_provider(requested_norm)\n+            canonical = auth_mod.resolve_provider(requested_norm)\n         except AuthError:\n             pass\n         else:\n-            return None\n+            # A user-declared ``custom_providers`` entry whose name matches\n+            # only an *alias* (``kimi`` → built-in ``kimi-coding``) is the\n+            # user's intended target — alias rewriting would otherwise hijack\n+            # the request.  We only defer to the built-in when the raw name is\n+            # the canonical provider itself (``nous``, ``openrouter``, …) so\n+            # accidentally shadowing a canonical provider still resolves to\n+            # the built-in. See tests/hermes_cli/test_runtime_provider_resolution.py\n+            # ``test_named_custom_provider_does_not_shadow_builtin_provider``.\n+            if (canonical or \"\").strip().lower() == requested_norm:\n+                return None\n \n     config = load_config()\n     "]},{"file":"tests/agent/test_auxiliary_named_custom_providers.py","hunks":["@@ -427,3 +427,68 @@ def test_provider_without_api_mode_still_uses_openai(self, tmp_path):\n         assert isinstance(sync_client, OpenAI)\n         async_client, _ = resolve_provider_client(\"localchat\", async_mode=True)\n         assert isinstance(async_client, AsyncOpenAI)\n+\n+\n+class TestCustomProviderAliasCollision:\n+    \"\"\"A user-declared custom_providers entry whose name matches a built-in\n+    *alias* (not a canonical provider) must win over the built-in.\n+\n+    Regression guard for #15743: users who defined fallback_model pointing at\n+    a custom_providers entry named ``kimi`` were having requests routed to\n+    the built-in kimi-coding endpoint because ``_normalize_aux_provider``\n+    rewrote ``kimi`` → ``kimi-coding`` before the named-custom lookup.\n+    \"\"\"\n+\n+    def test_custom_named_kimi_wins_over_builtin_alias(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"provider\": \"openrouter\", \"default\": \"anthropic/claude-sonnet-4.6\"},\n+            \"custom_providers\": [\n+                {\n+                    \"name\": \"kimi\",\n+                    \"base_url\": \"https://my-custom-kimi.example.com/v1\",\n+                    \"api_key\": \"[redacted:secret-assignment]\",\n+                    \"models\": {\"my-kimi-model\": {\"context_length\": 200000}},\n+                },\n+            ],\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        from openai import OpenAI\n+        client, model = resolve_provider_client(\"kimi\", model=\"my-kimi-model\", raw_codex=True)\n+        assert isinstance(client, OpenAI)\n+        assert \"my-custom-kimi.example.com\" in str(client.base_url)\n+        assert client.api_key == \"my-kimi-key\"\n+        assert model == \"my-kimi-model\"\n+\n+    def test_bare_kimi_without_custom_still_routes_to_builtin(self, tmp_path, monkeypatch):\n+        \"\"\"Regression guard: bare 'kimi' with no custom entry must still\n+        reach the built-in kimi-coding provider.\"\"\"\n+        _write_config(tmp_path, {\n+            \"model\": {\"provider\": \"openrouter\", \"default\": \"anthropic/claude-sonnet-4.6\"},\n+        })\n+        monkeypatch.setenv(\"KIMI_API_KEY\", \"builtin-kimi-key\")\n+        from agent.auxiliary_client import resolve_provider_client\n+        client, _ = resolve_provider_client(\"kimi\", model=\"kimi-k2-0905-preview\", raw_codex=True)\n+        assert client is not None\n+        base_url = str(client.base_url)\n+        # Built-in kimi-coding points at api.moonshot.ai\n+        assert \"moonshot\" in base_url or \"kimi\" in base_url, f\"unexpected base_url {base_url!r}\"\n+\n+    def test_explicit_overrides_applied_on_api_key_branch(self, tmp_path, monkeypatch):\n+        \"\"\"Explicit base_url/api_key from the caller must override the\n+        registered provider's defaults on the API-key branch.  Used by\n+        _try_activate_fallback to route a fallback through a built-in\n+        provider name but targeting a user-supplied endpoint.\"\"\"\n+        _write_config(tmp_path, {\n+            \"model\": {\"provider\": \"openrouter\", \"default\": \"anthropic/claude-sonnet-4.6\"},\n+        })\n+        monkeypatch.setenv(\"KIMI_API_KEY\", \"builtin-kimi-key\")\n+        from agent.auxiliary_client import resolve_provider_client\n+        from openai import OpenAI\n+        client, _ = resolve_provider_client(\n+            \"kimi-coding\", model=\"kimi-k2\", raw_codex=True,\n+            explicit_base_url=\"https://override.example.com\",\n+            explicit_api_key=\"[redacted:secret-assignment]\",\n+        )\n+        assert isinstance(client, OpenAI)\n+        assert \"override.example.com\" in str(client.base_url)\n+        assert client.api_key == \"override-key\""]},{"file":"tests/hermes_cli/test_runtime_provider_resolution.py","hunks":["@@ -897,6 +897,58 @@ def test_named_custom_provider_does_not_shadow_builtin_provider(monkeypatch):\n     assert resolved[\"requested_provider\"] == \"nous\"\n \n \n+def test_named_custom_provider_wins_over_builtin_alias(monkeypatch):\n+    \"\"\"A custom_providers entry named after a built-in *alias* (not a canonical\n+    provider name) must win over the built-in.  Regression guard for #15743:\n+    when users define ``custom_providers: [{name: kimi, ...}]`` and reference\n+    ``provider: kimi``, the built-in alias rewriting (``kimi`` → ``kimi-coding``)\n+    would otherwise hijack the request and send it to the wrong endpoint.\n+    \"\"\"\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\n+                    \"name\": \"kimi\",\n+                    \"base_url\": \"https://my-custom-kimi.example.com/v1\",\n+                    \"api_key\": \"[redacted:secret-assignment]\",\n+                }\n+            ]\n+        },\n+    )\n+\n+    entry = rp._get_named_custom_provider(\"kimi\")\n+\n+    assert entry is not None\n+    assert entry[\"base_url\"] == \"https://my-custom-kimi.example.com/v1\"\n+    assert entry[\"api_key\"] == \"my-kimi-key\"\n+\n+\n+def test_named_custom_provider_skipped_for_canonical_built_in(monkeypatch):\n+    \"\"\"Companion to the test above: ``nous`` is a canonical provider name\n+    (``resolve_provider('nous') == 'nous'``), so a custom entry with that name\n+    should NOT be returned — the built-in wins as before.\n+    \"\"\"\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"custom_providers\": [\n+                {\n+                    \"name\": \"nous\",\n+                    \"base_url\": \"http://localhost:1234/v1\",\n+                    \"api_key\": \"[redacted:secret-assignment]\",\n+                }\n+            ]\n+        },\n+    )\n+\n+    entry = rp._get_named_custom_provider(\"nous\")\n+\n+    assert entry is None\n+\n+\n def test_explicit_openrouter_skips_openai_base_url(monkeypatch):\n     \"\"\"When the user explicitly requests openrouter, OPENAI_BASE_URL\n     (which may point to a custom endpoint) must not override the\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}