auxiliary tasks failing to inherit custom provider credentials

NousResearch/hermes-agent · cluster 16719 · agent

Data as of

3 open items. Fix merged in #35259. Check it covers your case.

The short answer

2open issues
1open PR
16items grouped
Known?
Yes. 16 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #35259. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #35259. That is separate from the fix event.
Still open
2 issues and 1 PR.

Multiple issues and fixes addressing auxiliary tasks (compression, title generation, etc.) not correctly inheriting base_url, api_key, and api_mode from named custom providers. The cluster centers on credential and endpoint routing failures in auxiliary task execution when a custom provider is configured.

Open issues2

  • Issue#17737Auxiliary tasks don't inherit provider-level base_url/api_key from config
  • Issue#22317[Bug] Auxiliary auto-detect ignores named custom providers, routes all unconfigured tasks to main provider

Open PRs1

  • PR#18337fix: resolve 7 identified issues [automated]

Fixes on record

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

  • #35259 fix(auxiliary): route custom-provider aux tasks to the user endpoint (salvage #34783)

    merged to main

    files: agent/auxiliary_client.py, agent/conversation_loop.py, tests/agent/test_set_runtime_main_custom_provider.py

    Show the diff
    • agent/auxiliary_client.py
      @@ -1680,26 +1680,48 @@ def _read_main_provider() -> str:
       # per turn — no lock needed. Cleared by ``clear_runtime_main()``.
       _RUNTIME_MAIN_PROVIDER: str = ""
       _RUNTIME_MAIN_MODEL: str = ""
      +_RUNTIME_MAIN_BASE_URL: str = ""
      +_RUNTIME_MAIN_API_KEY: str = ""
      +_RUNTIME_MAIN_API_MODE: str = ""
       
       
      -def set_runtime_main(provider: str, model: str) -> None:
      -    """Record the live runtime provider/model for the current AIAgent.
      +def set_runtime_main(
      +    provider: str,
      +    model: str,
      +    *,
      +    base_url: str = "",
      +    api_key: str = "",
      +    api_mode: str = "",
      +) -> None:
      +    """Record the live runtime provider/model/credentials for the current AIAgent.
       
           Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or
           equivalent setter) at the top of each turn so that
           ``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway
           overrides instead of the stale config.yaml default.
      +
      +    For ``custom:`` providers, ``base_url`` and ``api_key`` must also be
      +    recorded so that ``_resolve_auto`` can construct a valid client in
      +    Step 1 instead of falling through to the aggregator chain.
           """
           global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
      +    global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
           _RUNTIME_MAIN_PROVIDER = (provider or "").strip().lower()
           _RUNTIME_MAIN_MODEL = (model or "").strip()
      +    _RUNTIME_MAIN_BASE_URL = (base_url or "").strip()
      +    _RUNTIME_MAIN_API_KEY = [redacted:secret-assignment] if isinstance(api_key, str) else ""
      +    _RUNTIME_MAIN_API_MODE = (api_mode or "").strip()
       
       
       def clear_runtime_main() -> None:
           """Clear the runtime override (e.g. on session end)."""
           global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
      +    global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
           _RUNTIME_MAIN_PROVIDER = ""
           _RUNTIME_MAIN_MODEL = ""
      +    _RUNTIME_MAIN_BASE_URL = ""
      +    _RUNTIME_MAIN_API_KEY = ""
      +    _RUNTIME_MAIN_API_MODE = ""
       
       
       def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
      @@ -2980,6 +3002,18 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
           runtime_api_key = [redacted:secret-assignment]"api_key", "")
           runtime_api_mode = str(runtime.get("api_mode") or "")
       
      +    # Fall back to process-local globals when main_runtime dict was not
      +    # provided or was incomplete.  ``set_runtime_main()`` now records
      +    # base_url/api_key/api_mode alongside provider/model, so custom:
      +    # providers get the full credential surface in Step 1 of the
      +    # auto-detect chain.
      +    if not runtime_base_url and _RUNTIME_MAIN_BASE_URL:
      +        runtime_base_url = _RUNTIME_MAIN_BASE_URL
      +    if not runtime_api_key and _RUNTIME_MAIN_API_KEY:
      +        runtime_api_key = [redacted:secret-assignment]
      +    if not runtime_api_mode and _RUNTIME_MAIN_API_MODE:
      +        runtime_api_mode = _RUNTIME_MAIN_API_MODE
      +
           # ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
           #    provider (not 'custom').  This catches the common "env poisoning"
           #    scenario where a user switches providers via `hermes model` but the
    • agent/conversation_loop.py
      @@ -392,6 +392,9 @@ def run_conversation(
               set_runtime_main(
                   getattr(agent, "provider", "") or "",
                   getattr(agent, "model", "") or "",
      +            base_url=getattr(agent, "base_url", "") or "",
      +            api_key=[redacted:secret-assignment] "api_key", "") or "",
      +            api_mode=getattr(agent, "api_mode", "") or "",
               )
           except Exception:
               pass
    • tests/agent/test_set_runtime_main_custom_provider.py
      @@ -0,0 +1,226 @@
      +"""Regression test: set_runtime_main() must pass base_url/api_key/api_mode
      +so that _resolve_auto() can route custom: providers in Step 1.
      +
      +Fixes https://github.com/NousResearch/hermes-agent/issues/34777
      +"""
      +import pytest
      +from unittest.mock import patch, MagicMock
      +
      +
      +def _get_globals(mod):
      +    """Read runtime globals without triggering redaction."""
      +    return {
      +        "provider": mod._RUNTIME_MAIN_PROVIDER,
      +        "model": mod._RUNTIME_MAIN_MODEL,
      +        "base_url": mod._RUNTIME_MAIN_BASE_URL,
      +        "cred": mod._RUNTIME_MAIN_API_KEY,  # renamed to avoid redaction
      +        "api_mode": mod._RUNTIME_MAIN_API_MODE,
      +    }
      +
      +
      +class TestSetRuntimeMainCustomProvider:
      +    """set_runtime_main must propagate base_url/api_key/api_mode for custom providers."""
      +
      +    def test_globals_stored(self):
      +        """set_runtime_main stores all five fields in process-local globals."""
      +        import agent.auxiliary_client as mod
      +
      +        mod.clear_runtime_main()
      +        try:
      +            mod.set_runtime_main(
      +                "custom:my-router",
      +                "glm-5.1",
      +                base_url="https://my-server.example.com/v1",
      +                api_key="[redacted:secret-assignment]",
      +                api_mode="chat_completions",
      +            )
      +            g = _get_globals(mod)
      +            assert g["provider"] == "custom:my-router"
      +            assert g["model"] == "glm-5.1"
      +            assert g["base_url"] == "https://my-server.example.com/v1"
      +            assert g["cred"] == "sk-test-key"
      +            assert g["api_mode"] == "chat_completions"
      +        finally:
      +            mod.clear_runtime_main()
      +
      +    def test_clear_resets_all_globals(self):
      +        """clear_runtime_main resets all five globals to empty."""
      +        import agent.auxiliary_client as mod
      +
      +        mod.set_runtime_main(
      +            "custom:x", "m",
      +            base_url="https://x.example.com",
      +            api_key="[redacted:secret-assignment]",
      +            api_mode="chat_completions",
      +        )
      +        mod.clear_runtime_main()
      +        g = _get_globals(mod)
      +        for v in g.values():
      +            assert v == "", f"Expected empty, got {v!r}"
      +
      +    def test_resolve_auto_uses_globals_for_custom_provider(self):
      +        """_resolve_auto reads base_url/api_key from globals when main_runtime is None."""
      +        import agent.auxiliary_client as mod
      +
      +        mod.clear_runtime_main()
      +        try:
      +            mod.set_runtime_main(
      +                "custom:test-router",
      +                "test-model",
      +                base_url="https://custom-endpoint.example.com/v1",
      +                api_key="[redacted:secret-assignment]",
      +            )
      +
      +            with patch.object(mod, "resolve_provider_client") as mock_resolve:
      +                mock_resolve.return_value = (MagicMock(), "test-model")
      +                client, resolved = mod._resolve_auto(main_runtime=None)
      +
      +                mock_resolve.assert_called_once()
      +                call_args = mock_resolve.call_args
      +                assert call_args[0][0] == "custom"
      +                assert call_args[1]["explicit_base_url"] == "https://custom-endpoint.example.com/v1"
      +                assert call_args[1]["explicit_api_key"] == "sk-test-123"
      +        finally:
      +            mod.clear_runtime_main()
      +
      +    def test_explicit_main_runtime_takes_precedence(self):
      +        """When main_runtime dict has values, globals are NOT used."""
      +        import agent.auxiliary_client as mod
      +
      +        mod.clear_runtime_main()
      +        try:
      +            mod.set_runtime_main(
      +                "custom:router-a",
      +                "model-a",
      +                base_url="https://from-global.example.com",
      +                api_key="[redacted:secret-assignment]",
      +            )
      +
      +            with patch.object(mod, "resolve_provider_client") as mock_resolve:
      +                mock_resolve.return_value = (MagicMock(), "model-b")
      +                main_rt = {
      +                    "provider": "custom:router-b",
      +                    "model": "model-b",
      +                    "base_url": "https://from-dict.example.com",
      +                    "api_key": "sk-dict",
      +                }
      +                mod._resolve_auto(main_runtime=main_rt)
      +
      +                call_args = mock_resolve.call_args[1]
      +                assert call_args["explicit_base_url"] == "https://from-dict.example.com"
      +                assert call_args["explicit_api_key"] == "sk-dict"
      +        finally:
      +            mod.clear_runtime_main()
      +
      +    def test_backward_compatible_defaults(self):
      +        """Calling set_runtime_main with only positional args still works."""
      +        import agent.auxiliary_client as mod
      +
      +        mod.clear_runtime_main()
      +        try:
      +            mod.set_runtime_main("openrouter", "gpt-4o")
      +            g = _get_globals(mod)
      +            assert g["provider"] == "openrouter"
      +            assert g["model"] == "gpt-4o"
      +            assert g["base_url"] == ""
      +            assert g["cred"] == ""
      +            assert g["api_mode"] == ""
      +        finally:
      +            mod.clear_runtime_main()
      +
      +
      +class TestResolveAutoCustomEndToEnd:
      +    """End-to-end routing assertions — build a *real* client (no mock on
      +    resolve_provider_client) and verify the auxiliary auto-detect chain lands
      +    on the user's custom endpoint instead of falling through to the aggregator
      +    chain.  These guard the actual user-visible symptom in #34777 (aux tasks
      +    silently routed to a fallback provider) rather than just the wiring.
      +    """
      +
      +    @staticmethod
      +    def _client_base_url(client):
      +        for chain in (("base_url",), ("_client", "base_url")):
      +            obj = client
      +            try:
      +                for attr in chain:
      +                    obj = getattr(obj, attr)
      +                return str(obj)
      +            except AttributeError:
      +                continue
      +        return None
      +
      +    def test_config_less_custom_endpoint_routes_via_global(self, tmp_path, monkeypatch):
      +        """custom:<name> with NO config entry: the live base_url carried by
      +        set_runtime_main() must build a real client at that endpoint — not
      +        fall through to Step 2 (the regression in #34777)."""
      +        import agent.auxiliary_client as mod
      +
      +        # Hermetic: no aggregator creds, no stale OPENAI_BASE_URL.
      +        for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
      +                    "OPENAI_BASE_URL"):
      +            monkeypatch.delenv(var, raising=False)
      +        hermes_home = tmp_path / ".hermes"
      +        hermes_home.mkdir()
      +        (hermes_home / "config.yaml").write_text(
      +            "model:\n"
      +            "  default: glm-5.1\n"
      +            "  provider: 'custom:ephemeral'\n"
      +            "  base_url: ''\n"
      +        )
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +
      +        mod.clear_runtime_main()
      +        try:
      +            mod.set_runtime_main(
      +                "custom:ephemeral",
      +                "glm-5.1",
      +                base_url="https://ephemeral.live/v1",
      +                api_key="[redacted:secret-assignment]",
      +            )
      +            client, resolved = mod.resolve_provider_client("auto", None)
      +            assert client is not None, (
      +                "config-less custom endpoint fell through to Step 2 — "
      +                "the #34777 bug is back"
      +            )
      +            assert resolved == "glm-5.1"
      +            base = self._client_base_url(client)
      +            assert base and base.rstrip("/") == "https://ephemeral.live/v1"
      +        finally:
      +            mod.clear_runtime_main()
      +
      +    def test_named_custom_with_config_entry_still_routes(self, tmp_path, monkeypatch):
      +        """Regression guard: custom:<name> WITH a custom_providers entry must
      +        still resolve to that entry's endpoint.  An earlier competing fix
      +        collapsed the provider to bare ``custom`` before resolution, which
      +        broke the named-custom branch and returned None here."""
      +        import agent.auxiliary_client as mod
      +
      +        for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
      +                    "OPENAI_BASE_URL"):
      +            monkeypatch.delenv(var, raising=False)
      +        hermes_home = tmp_path / ".hermes"
      +        hermes_home.mkdir()
      +        (hermes_home / "config.yaml").write_text(
      +            "model:\n"
      +            "  default: glm-5.1\n"
      +            "  provider: 'custom:openclaw'\n"
      +            "  base_url: ''\n"
      +            "custom_providers:\n"
      +            "  - name: openclaw\n"
      +            "    base_url: 'https://withcfg.example/v1'\n"
      +            "    model: glm-5.1\n"
      +            "    api_key: [redacted:secret-assignment]"
      +        )
      +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +
      +        # No live base_url carried — resolution must come from config alone,
      +        # via the named-custom branch in resolve_provider_client.
      +        mod.clear_runtime_main()
      +        try:
      +            mod.set_runtime_main("custom:openclaw", "glm-5.1")
      +            client, resolved = mod.resolve_provider_client("auto", None)
      +            assert client is not None
      +            base = self._client_base_url(client)
      +            assert base and base.rstrip("/") == "https://withcfg.example/v1"
      +        finally:
      +            mod.clear_runtime_main()
  • #59301 fix(auxiliary): reuse main_runtime credentials for named custom providers

    merged to main

    files: agent/auxiliary_client.py, tests/agent/test_auxiliary_named_custom_providers.py

    Show the diff
    • agent/auxiliary_client.py
      @@ -4307,6 +4307,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
       
           # ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
           if provider == "custom":
      +        custom_base = ""
      +        custom_key = ""
               if explicit_base_url:
                   custom_base = _to_openai_base_url(explicit_base_url).strip()
                   custom_key = (
      @@ -4321,6 +4323,19 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
                           "but base_url is empty"
                       )
                       return None, None
      +        elif main_runtime:
      +            # When main_runtime carries a concrete base_url + api_key for a
      +            # named custom provider (custom:<name>), use it directly instead
      +            # of re-resolving from the bare "custom" provider name.
      +            # Re-resolution loses the provider name and falls back to
      +            # OpenRouter or a wrong API-key provider — the main agent already
      +            # solved this, we just need to reuse its answer. (#45472)
      +            _main_base = str(main_runtime.get("base_url") or "").strip().rstrip("/")
      +            _main_key = str(main_runtime.get("api_key") or "").strip()
      +            if _main_base and _main_key:
      +                custom_base = _main_base
      +                custom_key = _main_key
      +        if custom_base and custom_key:
                   final_model = _normalize_resolved_model(
                       model or (main_runtime.get("model") if main_runtime else None) or "gpt-4o-mini",
                       provider,
    • tests/agent/test_auxiliary_named_custom_providers.py
      @@ -491,3 +491,73 @@ def test_explicit_overrides_applied_on_api_key_branch(self, tmp_path, monkeypatc
               assert isinstance(client, OpenAI)
               assert "override.example.com" in str(client.base_url)
               assert client.api_key == "override-key"
      +
      +
      +class TestResolveProviderClientMainRuntimeCustom:
      +    """When the main agent uses a named custom provider (custom:<name>),
      +    resolve_provider_client('custom', ..., main_runtime=...) must reuse the
      +    main_runtime's base_url + api_key instead of re-resolving from the bare
      +    'custom' provider name.  Re-resolution loses the provider name and falls
      +    back to OpenRouter or a wrong API-key provider. (#45472)"""
      +
      +    def test_custom_provider_main_runtime_used_directly(self, tmp_path, monkeypatch):
      +        """main_runtime with base_url + api_key for a named custom provider
      +        is used directly, bypassing the _try_custom_endpoint / API-key
      +        fallback chain."""
      +        from agent.auxiliary_client import resolve_provider_client
      +        main_runtime = {
      +            "provider": "custom",
      +            "base_url": "https://my-gateway.example.com/v1",
      +            "api_key": "***",
      +            "model": "glm-5.1",
      +        }
      +        client, model = resolve_provider_client(
      +            "custom",
      +            model="explicit-glm-5.1",
      +            main_runtime=main_runtime,
      +        )
      +        assert client is not None
      +        assert model == "explicit-glm-5.1"
      +        assert "my-gateway.example.com" in str(client.base_url)
      +        assert client.api_key == "***"
      +
      +    def test_custom_provider_main_runtime_no_credentials_falls_through(self, tmp_path, monkeypatch):
      +        """When main_runtime has no base_url or no api_key, the existing
      +        _try_custom_endpoint / _resolve_api_key_provider fallback chain is
      +        still tried."""
      +        # Ensure no env-provided credentials interfere
      +        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
      +        monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
      +        monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
      +        monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
      +
      +        from agent.auxiliary_client import resolve_provider_client
      +        # main_runtime with key but no base_url → must fall through
      +        client, model = resolve_provider_client(
      +            "custom",
      +            main_runtime={"api_key": "k", "base_url": ""},
      +        )
      +        # Should fall through to _try_custom_endpoint → return None,None
      +        # because no OPENAI_BASE_URL is set and no custom endpoint is configured
      +        assert client is None
      +
      +    def test_custom_provider_main_runtime_respects_explicit_base_url(self, tmp_path):
      +        """explicit_base_url still wins over main_runtime — the caller's
      +        explicit argument is the strongest signal."""
      +        from agent.auxiliary_client import resolve_provider_client
      +        main_runtime = {
      +            "base_url": "https://main-runtime.example.com/v1",
      +            "api_key": "sk-main",
      +            "model": "ignored-model",
      +        }
      +        client, model = resolve_provider_client(
      +            "custom",
      +            model="explicit-model",
      +            explicit_base_url="https://explicit.example.com/v1",
      +            explicit_api_key="[redacted:secret-assignment]",
      +            main_runtime=main_runtime,
      +        )
      +        assert client is not None
      +        assert model == "explicit-model"
      +        assert "explicit.example.com" in str(client.base_url)
      +        assert client.api_key == "sk-explicit"

7 PRs closed without merging

From the close comments: merged via another change 2 · closed without comment 1 · done elsewhere 1 · duplicate 1 · stale or abandoned 1 · other 1

  • #16819 fix(auxiliary): custom provider URL rewrite + main_runtime model for title gen · merged via another change → f3371c3, #16922
  • #17760 fix(auxiliary): inherit provider-level base_url/api_key for auxiliary tasks · stale or abandoned
  • #22368 fix(auxiliary): prefer named custom provider default_model over main model (#22317) · other
  • #34783 fix(auxiliary): pass base_url/api_key/api_mode through set_runtime_main for custom providers · duplicate → #33347, #34777, #33333, #17846
  • #36788 fix(cli): propagate --provider override to auxiliary title generation (#36748) · closed without comment
Show 2 more
  • #45531 fix: custom provider auxiliary client resolution failures · merged via another change → 5e11628, 92da7a9, #59301
  • #109442 fix(cli,model_switch): preserve named custom provider identity and base_url for URL-bearing aliases · done elsewhere
Closed issues · 4
  • Closed#16719Auxiliary tasks (compression, title generation) ignore custom base_url for named providers
  • Closed#34777【Bug】set_runtime_main() missing base_url/api_key causes custom: provider fallback to wrong endpoint
  • Closed#36748CLI provider override is not applied to auxiliary title generation for named custom providers
  • Closed#45480auxiliary auto-detect falls through to wrong provider when main provider is named custom (custom_providers/providers dict)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.10: 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

Subsystem
agent
Merged PRs
#35259, #59301
Closed issues
4
Attempts
7 PRs closed without merging
Fix link checked
6 Jul 2026

For your agent

The same facts, typed.