NousResearch/hermes-agent

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

0open issues
0open PRs
4items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #18185 fix(fallback): let custom_providers shadow built-in aliases

    merged to main

    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

    Show 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

  • #15756 fix(fallback): resolve base_url from custom_providers when fallback entry has none (#15743) · done elsewhere → 6ddc48b, #15743, #22780, #22842, #21219
  • #17827 fix(fallback): look up custom_providers base_url before fallback API call · merged via another change → 0ddc8ab, #18185
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.00 means 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 Lab

At a glance

Merged PRs
#18185
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
1 May 2026

For your agent

The same facts, typed.