# NousResearch/hermes-agent · Fallback provider base_url resolution from custom_providers

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #18185. Verify it addresses your case.
- **Recommended:** read PR #18185 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-18185`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #15743: Fallback provider defined in custom_providers ignores its own base_url, sends request to primary provider's endpoint instead

## Prior & competing PRs

- **#18185: fix(fallback): let custom_providers shadow built-in aliases**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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":
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#15756: fix(fallback): resolve base_url from custom_providers when fallback entry has none (#15743)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `6ddc48b05`, `#15743`, `#22780`, `#22842`, `#21219`
- **#17827: fix(fallback): look up custom_providers base_url before fallback API call**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `0ddc8aba6826c316060ff72f571f14bbba7058a8`, `#18185`

## Fix status

- Merged to main in PR #18185 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-01`.
