Auxiliary task API key resolution and credential lookup

NousResearch/hermes-agent · cluster 9318 · agent

Data as of

4 open items. Fix merged in #20215. Check it covers your case.

The short answer

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

These issues and fixes concern how auxiliary tasks (including vision) resolve API keys and provider identity when base_url, api_key, or named providers are set in various combinations. A recurring pattern involves credentials being dropped or misidentified, leading to 'no-key-required' fallbacks and 401 errors.

Open issues1

  • Issue#26879[Bug] auxiliary task provider identity lost when base_url + api_key are both set

Open PRs3

  • PR#16727fix(auxiliary): resolve named provider credentials when base_url is set
  • PR#26909fix(auxiliary): preserve named provider on custom endpoint
  • PR#106916fix(agent): lift named custom provider key when a task base_url flattens it to bare custom

Fixes on record

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

  • #20215 fix(auxiliary): avoid locking into custom path when api_key is empty (salvage #16944)

    merged to main

    files: agent/auxiliary_client.py

    Show the diff
    • agent/auxiliary_client.py
      @@ -3132,8 +3132,14 @@ def _resolve_task_provider_model(
       
           if task:
               # Config.yaml is the primary source for per-task overrides.
      -        if cfg_base_url:
      +        if cfg_base_url and cfg_api_key:
      +            # Both base_url and api_key explicitly set → custom endpoint.
                   return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
      +        if cfg_base_url and cfg_provider and cfg_provider != "auto":
      +            # base_url set without api_key but with a known provider — use
      +            # the provider so it can resolve credentials from env vars
      +            # (e.g. OPENROUTER_API_KEY) instead of locking into "custom".
      +            return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode
               if cfg_provider and cfg_provider != "auto":
                   return cfg_provider, resolved_model, None, None, resolved_api_mode
       
  • #21219 fix(credential_pool): resolve key mix-up when custom providers share base_url (salvage #19094)

    merged to main

    files: agent/credential_pool.py, hermes_cli/runtime_provider.py, tests/agent/test_credential_pool.py

    Show the diff
    • agent/credential_pool.py
      @@ -305,14 +305,29 @@ def _iter_custom_providers(config: Optional[dict] = None):
               yield _normalize_custom_pool_name(name), entry
       
       
      -def get_custom_provider_pool_key(base_url: str) -> Optional[str]:
      +def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]:
           """Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url.
       
      +    When provider_name is given, prefer matching by name first (solving the case where
      +    multiple custom providers share the same base_url but have different API keys).
      +    Falls back to base_url matching when no name match is found.
      +
           Returns None if no match is found.
           """
           if not base_url:
               return None
           normalized_url = base_url.strip().rstrip("/")
      +
      +    # When a provider name is given, try to match by name first.
      +    # This fixes the P1 bug where two custom providers sharing the same
      +    # base_url always resolve to the first one's credentials.
      +    if provider_name:
      +        normalized_name = _normalize_custom_pool_name(provider_name)
      +        for norm_name, entry in _iter_custom_providers():
      +            if norm_name == normalized_name:
      +                return f"{CUSTOM_POOL_PREFIX}{norm_name}"
      +
      +    # Fall back to base_url matching (original behavior)
           for norm_name, entry in _iter_custom_providers():
               entry_url = str(entry.get("base_url") or "").strip().rstrip("/")
               if entry_url and entry_url == normalized_url:
    • hermes_cli/runtime_provider.py
      @@ -319,9 +319,10 @@ def _try_resolve_from_custom_pool(
           base_url: str,
           provider_label: str,
           api_mode_override: Optional[str] = None,
      +    provider_name: Optional[str] = None,
       ) -> Optional[Dict[str, Any]]:
           """Check if a credential pool exists for a custom endpoint and return a runtime dict if so."""
      -    pool_key = get_custom_provider_pool_key(base_url)
      +    pool_key = get_custom_provider_pool_key(base_url, provider_name=provider_name)
           if not pool_key:
               return None
           try:
      @@ -521,7 +522,7 @@ def _resolve_named_custom_runtime(
               return None
       
           # Check if a credential pool exists for this custom endpoint
      -    pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"))
      +    pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"), provider_name=custom_provider.get("name"))
           if pool_result:
               # Propagate the model name even when using pooled credentials —
               # the pool doesn't know about the custom_providers model field.
      @@ -640,8 +641,11 @@ def _resolve_openrouter_runtime(
       
           # For custom endpoints, check if a credential pool exists
           if effective_provider == "custom" and base_url:
      +        # Pass requested_provider so pool lookup prefers name match over base_url,
      +        # fixing credential mix-ups when multiple custom providers share a base_url.
               pool_result = _try_resolve_from_custom_pool(
                   base_url, effective_provider, _parse_api_mode(model_cfg.get("api_mode")),
      +            provider_name=requested_provider if requested_norm != "custom" else None,
               )
               if pool_result:
                   return pool_result
    • tests/agent/test_credential_pool.py
      @@ -924,6 +924,43 @@ def test_get_custom_provider_pool_key(tmp_path, monkeypatch):
           assert get_custom_provider_pool_key("") is None
       
       
      +def test_get_custom_provider_pool_key_prefers_name_over_base_url(tmp_path, monkeypatch):
      +    """When two custom providers share the same base_url, provider_name resolves to the correct one."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    (tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
      +    import yaml
      +    config_path = tmp_path / "hermes" / "config.yaml"
      +    config_path.write_text(yaml.dump({
      +        "custom_providers": [
      +            {
      +                "name": "provider-a",
      +                "base_url": "http://gateway:8080/v1",
      +                "api_key": "sk-aaa",
      +            },
      +            {
      +                "name": "provider-b",
      +                "base_url": "http://gateway:8080/v1",
      +                "api_key": "sk-bbb",
      +            },
      +        ]
      +    }))
      +
      +    from agent.credential_pool import get_custom_provider_pool_key
      +
      +    # Without provider_name, first match wins (backward compatible)
      +    assert get_custom_provider_pool_key("http://gateway:8080/v1") == "custom:provider-a"
      +
      +    # With provider_name, exact name match wins regardless of order
      +    assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-b") == "custom:provider-b"
      +    assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-a") == "custom:provider-a"
      +
      +    # Name match with non-matching base_url still works via fallback
      +    assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="nonexistent") == "custom:provider-a"
      +
      +    # Empty provider_name is same as None (backward compatible)
      +    assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="") == "custom:provider-a"
      +
      +
       def test_list_custom_pool_providers(tmp_path, monkeypatch):
           """list_custom_pool_providers returns custom: pool keys from auth.json."""
           monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
  • #55605 fix(aux): preserve provider identity for resolved endpoints

    merged to main

    files: agent/auxiliary_client.py, tests/agent/test_auxiliary_client.py, tests/run_agent/test_moa_loop_mode.py

    Show the diff
    • agent/auxiliary_client.py
      @@ -5238,9 +5238,10 @@ def _resolve_task_provider_model(
             3. "auto" (full auto-detection chain)
       
           Returns (provider, model, base_url, api_key, api_mode) where model may
      -    be None (use provider default). When base_url is set, provider is forced
      -    to "custom" and the task uses that direct endpoint. api_mode is one of
      -    "chat_completions", "codex_responses", or None (auto-detect).
      +    be None (use provider default). A bare base_url is treated as custom, but
      +    a first-class provider plus base_url keeps the provider identity so its
      +    auth, transport, and request-shaping behavior still apply. api_mode is one
      +    of "chat_completions", "codex_responses", or None (auto-detect).
           """
           cfg_provider = None
           cfg_model = None
      @@ -5273,11 +5274,35 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str])
                   return prov, existing_base
               return "custom", existing_base or target_base
       
      +    def _preserve_provider_with_base_url(prov: Optional[str]) -> bool:
      +        normalized = str(prov or "").strip().lower()
      +        if normalized in {"", "auto", "custom"} or normalized.startswith("custom:"):
      +            return False
      +        try:
      +            from hermes_cli.providers import get_provider
      +
      +            return get_provider(normalized) is not None
      +        except Exception:
      +            # Keep the high-risk provider-backed routes safe even if provider
      +            # catalog loading is unavailable during early import/test paths.
      +            return normalized in {
      +                "anthropic",
      +                "copilot",
      +                "copilot-acp",
      +                "minimax-oauth",
      +                "nous",
      +                "openai-codex",
      +                "qwen-oauth",
      +                "xai-oauth",
      +            }
      +
           if provider:
               provider, base_url = _expand_direct_api_alias(provider, base_url)
           if cfg_provider:
               cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url)
       
      +    if base_url and _preserve_provider_with_base_url(provider):
      +        return provider, resolved_model, base_url, api_key, resolved_api_mode
           if base_url:
               return "custom", resolved_model, base_url, api_key, resolved_api_mode
           if provider:
    • tests/agent/test_auxiliary_client.py
      @@ -31,6 +31,7 @@
           _OPENROUTER_MODEL,
           OPENROUTER_BASE_URL,
           _resolve_auto,
      +    _resolve_task_provider_model,
           _resolve_xai_oauth_for_aux,
           _CodexCompletionsAdapter,
       )
      @@ -108,6 +109,65 @@ def test_uses_max_completion_tokens_for_github_copilot_custom_base_path(self):
                   assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048}
       
       
      +class TestResolveTaskProviderModel:
      +    @pytest.mark.parametrize(
      +        "provider",
      +        [
      +            "anthropic",
      +            "minimax-oauth",
      +            "nous",
      +            "openai-codex",
      +            "qwen-oauth",
      +            "xai-oauth",
      +        ],
      +    )
      +    def test_explicit_base_url_preserves_first_class_provider_identity(self, provider):
      +        resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(
      +            task="moa_reference",
      +            provider=provider,
      +            model="test-model",
      +            base_url="https://provider.example/v1",
      +            api_key="[redacted:secret-assignment]",
      +        )
      +
      +        assert resolved_provider == provider
      +        assert model == "test-model"
      +        assert base_url == "https://provider.example/v1"
      +        assert api_key == "resolved-token"
      +        assert api_mode is None
      +
      +    @pytest.mark.parametrize("provider", ["", "auto", "custom", "custom:local", "unknown-provider"])
      +    def test_explicit_base_url_without_first_class_provider_routes_as_custom(self, provider):
      +        resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(
      +            task="moa_reference",
      +            provider=provider,
      +            model="test-model",
      +            base_url="https://provider.example/v1",
      +            api_key="[redacted:secret-assignment]",
      +        )
      +
      +        assert resolved_provider == "custom"
      +        assert model == "test-model"
      +        assert base_url == "https://provider.example/v1"
      +        assert api_key == "resolved-token"
      +        assert api_mode is None
      +
      +    def test_direct_openai_alias_with_base_url_still_routes_as_custom(self):
      +        resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(
      +            task="vision",
      +            provider="openai",
      +            model="gpt-4o-mini",
      +            base_url="https://proxy.example/v1",
      +            api_key="[redacted:secret-assignment]",
      +        )
      +
      +        assert resolved_provider == "custom"
      +        assert model == "gpt-4o-mini"
      +        assert base_url == "https://proxy.example/v1"
      +        assert api_key == "sk-test"
      +        assert api_mode is None
      +
      +
       class TestBuildCallKwargsMaxTokens:
           """_build_call_kwargs should not cap output by default (#34530).
       
    • tests/run_agent/test_moa_loop_mode.py
      @@ -1,6 +1,8 @@
       from types import SimpleNamespace
       from unittest.mock import MagicMock
       
      +import pytest
      +
       from run_agent import AIAgent
       
       
      @@ -198,6 +200,47 @@ def fake_resolve(*, requested, target_model=None):
           assert rt == {"provider": "openai-codex", "model": "gpt-5.5"}
       
       
      [redacted:email]("provider", ["anthropic", "minimax-oauth", "qwen-oauth"])
      +def test_moa_provider_backed_slot_survives_aux_resolution(monkeypatch, provider):
      +    """MoA can pass resolved endpoints for provider-backed slots without
      +    call_llm flattening them to generic custom endpoints.
      +
      +    ``_slot_runtime`` resolves a provider-backed slot to ``provider`` plus a
      +    concrete ``base_url``/``api_key``/``api_mode``; ``_run_reference`` then
      +    forwards that dict to ``call_llm``. ``call_llm`` resolves the routing tuple
      +    via ``_resolve_task_provider_model`` (which takes everything except
      +    ``api_mode``, handled separately). The provider identity must survive that
      +    resolution rather than being flattened to ``custom``.
      +    """
      +    from agent import moa_loop
      +    from agent.auxiliary_client import _resolve_task_provider_model
      +
      +    def fake_resolve(*, requested, target_model=None):
      +        return {
      +            "provider": requested,
      +            "api_mode": "anthropic_messages",
      +            "base_url": f"https://{requested}.example/v1",
      +            "api_key": f"token-for-{requested}",
      +        }
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.runtime_provider.resolve_runtime_provider", fake_resolve
      +    )
      +
      +    rt = moa_loop._slot_runtime({"provider": provider, "model": "test-model"})
      +    # api_mode is forwarded to call_llm directly, not to _resolve_task_provider_model.
      +    resolver_kwargs = {k: v for k, v in rt.items() if k != "api_mode"}
      +    resolved_provider, model, base_url, api_key, _mode = _resolve_task_provider_model(
      +        task="moa_reference",
      +        **resolver_kwargs,
      +    )
      +
      +    assert resolved_provider == provider
      +    assert model == "test-model"
      +    assert base_url == f"https://{provider}.example/v1"
      +    assert api_key == f"token-for-{provider}"
      +
      +
       def test_moa_slot_runtime_falls_back_on_resolution_error(monkeypatch):
           """A slot whose provider can't be resolved still attempts the call with the
           bare provider/model rather than aborting the whole MoA turn."""
  • #59207 fix(auxiliary): inherit model.api_key for same-host custom endpoints when per-task key is empty

    merged to main

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

    Show the diff
    • agent/auxiliary_client.py
      @@ -2003,6 +2003,76 @@ def _read_main_provider() -> str:
           return ""
       
       
      +def _read_main_api_key() -> str:
      +    """Read the user's main model API key from the runtime override or config.
      +
      +    Mirrors ``_read_main_model`` / ``_read_main_provider``: checks the
      +    process-local ``_RUNTIME_MAIN_API_KEY`` override first (set by
      +    ``set_runtime_main`` when an AIAgent is active), then falls back to
      +    ``model.api_key`` in config.yaml.
      +
      +    Used by the ``custom`` provider fallback chain so that auxiliary tasks
      +    configured with an explicit ``base_url`` but empty ``api_key`` inherit
      +    the main model's credentials instead of falling to ``no-key-required``
      +    (issue #9318).
      +    """
      +    override = _RUNTIME_MAIN_API_KEY
      +    if isinstance(override, str) and override.strip():
      +        return override.strip()
      +    try:
      +        from hermes_cli.config import load_config
      +        cfg = load_config()
      +        model_cfg = cfg.get("model", {})
      +        if isinstance(model_cfg, dict):
      +            key = model_cfg.get("api_key", "")
      +            if isinstance(key, str) and key.strip():
      +                return key.strip()
      +    except Exception:
      +        pass
      +    return ""
      +
      +
      +def _read_main_base_url() -> str:
      +    """Read the main model's base_url from the runtime override or config.
      +
      +    Same override-then-config pattern as ``_read_main_api_key``.
      +    """
      +    override = _RUNTIME_MAIN_BASE_URL
      +    if isinstance(override, str) and override.strip():
      +        return override.strip()
      +    try:
      +        from hermes_cli.config import load_config
      +        cfg = load_config()
      +        model_cfg = cfg.get("model", {})
      +        if isinstance(model_cfg, dict):
      +            base = model_cfg.get("base_url", "")
      +            if isinstance(base, str) and base.strip():
      +                return base.strip()
      +    except Exception:
      +        pass
      +    return ""
      +
      +
      +def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
      +    """Return the main api_key only when *aux_base_url* points at the same
      +    host as the main model's base_url.
      +
      +    The #9318 use case is an auxiliary task sharing the main model's
      +    self-hosted gateway (same host, different model) with an empty per-task
      +    api_key. Inheriting unconditionally would send the main credential to
      +    ANY host a misconfigured aux base_url names — a cross-host credential
      +    leak. A host mismatch keeps the previous fail-safe behavior
      +    (``no-key-required`` → 401).
      +    """
      +    aux_host = base_url_hostname(aux_base_url)
      +    if not aux_host:
      +        return ""
      +    main_host = base_url_hostname(_read_main_base_url())
      +    if not main_host or aux_host != main_host:
      +        return ""
      +    return _read_main_api_key()
      +
      +
       # Process-local override set by AIAgent at session/turn start. Single-threaded
       # per turn — no lock needed. Cleared by ``clear_runtime_main()``.
       _RUNTIME_MAIN_PROVIDER: str = ""
      @@ -4242,6 +4312,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
                   custom_key = (
                       (explicit_api_key or "").strip()
                       or os.getenv("OPENAI_API_KEY", "").strip()
      +                or _read_main_api_key_if_same_host(custom_base)
                       or "no-key-required"  # local servers don't need auth
                   )
                   if not custom_base:
    • tests/agent/test_auxiliary_client.py
      @@ -5185,3 +5185,187 @@ def test_compression_task_uses_minimum_context_constant(self):
               # Empty / unknown tasks have no minimum
               assert _task_minimum_context_length("") is None
               assert _task_minimum_context_length(None) is None
      +
      +
      +class TestCustomEndpointApiKeyInheritance:
      +    """Issue #9318: when an auxiliary task uses provider=custom with an
      +    explicit base_url but empty api_key, the custom_key fallback chain must
      +    inherit ``model.api_key`` from config.yaml before falling to the
      +    ``no-key-required`` placeholder.
      +
      +    Without this fix, users on self-hosted gateways who share the same
      +    endpoint+credentials for both the main model and auxiliary tasks get 401
      +    auth errors because the placeholder key is sent instead of the real one.
      +
      +    Inheritance is host-gated: the main key is only inherited when the aux
      +    base_url points at the same host as the main model's base_url, so a
      +    misconfigured aux endpoint cannot leak the main credential cross-host.
      +    """
      +
      +    def test_inherits_main_api_key_when_aux_key_empty(self, monkeypatch):
      +        """RED→GREEN: explicit_api_key is None, OPENAI_API_KEY unset →
      +        model.api_key from config.yaml must be used (same-host gateway)."""
      +        import agent.auxiliary_client as ac
      +
      +        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
      +        monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
      +
      +        fake_config = {
      +            "model": {
      +                "api_key": "sk-main-config-key",
      +                "base_url": "https://gw.example.com/v1",
      +                "default": "main-model",
      +            }
      +        }
      +        captured: dict = {}
      +
      +        def _capture_create(**kwargs):
      +            captured.update(kwargs)
      +            return MagicMock()
      +
      +        with patch("hermes_cli.config.load_config", return_value=fake_config), \
      +             patch.object(ac, "_create_openai_client", side_effect=_capture_create):
      +            client, model = resolve_provider_client(
      +                "custom",
      +                model="test-model",
      +                explicit_base_url="https://gw.example.com/v1",
      +                explicit_api_key=None,
      +            )
      +
      +        assert captured.get("api_key") == "sk-main-config-key", (
      +            "Custom endpoint with empty api_key should inherit "
      +            "model.api_key from config, got: "
      +            + repr(captured.get("api_key"))
      +        )
      +
      +    def test_explicit_api_key_takes_precedence(self, monkeypatch):
      +        """explicit_api_key wins over config model.api_key."""
      +        import agent.auxiliary_client as ac
      +
      +        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
      +
      +        fake_config = {"model": {"api_key": "sk-main-config-key"}}
      +        captured: dict = {}
      +
      +        def _capture_create(**kwargs):
      +            captured.update(kwargs)
      +            return MagicMock()
      +
      +        with patch("hermes_cli.config.load_config", return_value=fake_config), \
      +             patch.object(ac, "_create_openai_client", side_effect=_capture_create):
      +            client, model = resolve_provider_client(
      +                "custom",
      +                model="test-model",
      +                explicit_base_url="https://gw.example.com/v1",
      +                explicit_api_key="[redacted:secret-assignment]",
      +            )
      +
      +        assert captured.get("api_key") == "sk-explicit"
      +
      +    def test_local_server_falls_to_no_key_required(self, monkeypatch):
      +        """When no key is available anywhere (explicit, env, config), fall
      +        back to ``no-key-required`` for local servers (Ollama, etc.)."""
      +        import agent.auxiliary_client as ac
      +
      +        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
      +
      +        fake_config = {"model": {}}  # no api_key configured
      +        captured: dict = {}
      +
      +        def _capture_create(**kwargs):
      +            captured.update(kwargs)
      +            return MagicMock()
      +
      +        with patch("hermes_cli.config.load_config", return_value=fake_config), \
      +             patch.object(ac, "_create_openai_client", side_effect=_capture_create):
      +            client, model = resolve_provider_client(
      +                "custom",
      +                model="test-model",
      +                explicit_base_url="http://localhost:11434/v1",
      +                explicit_api_key=None,
      +            )
      +
      +        assert captured.get("api_key") == "no-key-required"
      +
      +    def test_runtime_override_key_is_used(self, monkeypatch):
      +        """When _RUNTIME_MAIN_API_KEY is set (by set_runtime_main), it takes
      +        precedence over config.yaml for the custom endpoint key."""
      +        import agent.auxiliary_client as ac
      +
      +        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
      +
      +        captured: dict = {}
      +
      +        def _capture_create(**kwargs):
      +            captured.update(kwargs)
      +            return MagicMock()
      +
      +        with patch.object(ac, "_RUNTIME_MAIN_API_KEY", "sk-runtime-key"), \
      +             patch.object(ac, "_RUNTIME_MAIN_BASE_URL", "https://gw.example.com/v1"), \
      +             patch("hermes_cli.config.load_config", return_value={"model": {}}), \
      +             patch.object(ac, "_create_openai_client", side_effect=_capture_create):
      +            client, model = resolve_provider_client(
      +                "custom",
      +                model="test-model",
      +                explicit_base_url="https://gw.example.com/v1",
      +                explicit_api_key=None,
      +            )
      +
      +        assert captured.get("api_key") == "sk-runtime-key"
      +
      +    def test_cross_host_aux_endpoint_does_not_inherit_main_key(self, monkeypatch):
      +        """An aux base_url on a DIFFERENT host than the main model must NOT
      +        inherit model.api_key — that would leak the main credential to
      +        whatever host a misconfigured aux endpoint names. Falls back to the
      +        fail-safe no-key-required placeholder instead."""
      +        import agent.auxiliary_client as ac
      +
      +        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
      +
      +        fake_config = {
      +            "model": {
      +                "api_key": "sk-main-config-key",
      +                "base_url": "https://gw.example.com/v1",
      +            }
      +        }
      +        captured: dict = {}
      +
      +        def _capture_create(**kwargs):
      +            captured.update(kwargs)
      +            return MagicMock()
      +
      +        with patch("hermes_cli.config.load_config", return_value=fake_config), \
      +             patch.object(ac, "_create_openai_client", side_effect=_capture_create):
      +            client, model = resolve_provider_client(
      +                "custom",
      +                model="test-model",
      +                explicit_base_url="https://other-host.example.net/v1",
      +                explicit_api_key=None,
      +            )
      +
      +        assert captured.get("api_key") == "no-key-required"
      +
      +    def test_no_main_base_url_does_not_inherit_main_key(self, monkeypatch):
      +        """When the main model has no base_url (e.g. a first-class provider),
      +        there is no 'same gateway' to match — do not inherit the key."""
      +        import agent.auxiliary_client as ac
      +
      +        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
      +
      +        fake_config = {"model": {"api_key": "sk-main-config-key"}}
      +        captured: dict = {}
      +
      +        def _capture_create(**kwargs):
      +            captured.update(kwargs)
      +            return MagicMock()
      +
      +        with patch("hermes_cli.config.load_config", return_value=fake_config), \
      +             patch.object(ac, "_create_openai_client", side_effect=_capture_create):
      +            client, model = resolve_provider_client(
      +                "custom",
      +                model="test-model",
      +                explicit_base_url="https://gw.example.com/v1",
      +                explicit_api_key=None,
      +            )
      +
      +        assert captured.get("api_key") == "no-key-required"

11 PRs closed without merging

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

  • #9386 Fix custom provider API key precedence · superseded by another PR → #21219
  • #16308 fix(aux-client): resolve named-provider credential when base_url overrides endpoint (#16290) · stale or abandoned
  • #16389 fix(auxiliary.vision): keep named-provider credential pool with custom base_url · done elsewhere → 6cf7a9e, #16290
  • #16944 fix(auxiliary): avoid locking into custom path when api_key is empty · duplicate → #9318, #16829
  • #16950 fix(auxiliary_client): require api_key for custom provider path · duplicate → #16944, #9318, #16308
Show 6 more
  • #20139 fix(auxiliary): pass cfg_base_url and cfg_api_key when resolving task provider · closed without comment
  • #21286 fix(agent): inherit model.api_key when auxiliary base_url matches main endpoint · superseded by another PR → #20139, #17760
  • #34652 fix(auxiliary): preserve named provider identity when base_url is passed to _resolve_task_provider_model · duplicate → #34651, #16290, #26879, #16389, #16727, #26909
  • #49131 fix(agent): preserve vision provider for base_url fallback · closed without comment
  • #53688 fix(auxiliary): preserve provider when base_url is passed explicitly · done elsewhere → a8841e2, #55605, #39732
  • #55911 fix(auxiliary): inherit model.api_key for custom endpoint when per-task key is empty (#9318) · merged via another change → #59207
Closed issues · 9
  • Closed#9318[Bug]: Auxiliary client falls back to "no-key-required" when per-task custom base_url is set but api_key is empty
  • Closed#16290[Bug]: auxiliary.vision with provider=zai and custom base_url fails to auto-resolve ZAI_API_KEY
  • Closed#16829auxiliary.{task} with provider:auto + base_url set falls into 'custom' path with empty api_key, returns 401
  • Closed#21121[Bug]: auxiliary "use as" in dashboard copies base_url but omits api_key, causing 401 on authenticated custom endpoints
  • Closed#34651[Bug]: auxiliary vision returns 401 when provider has custom base_url — API key resolves to no-key-required
  • Closed#39599auxiliary vision: explicit base_url routes through generic "custom" branch, leaking main model name + OPENAI_API_KEY to the configured backend (Gemini)
  • Closed#47741Vision auxiliary with base_url set but no api_key → "no-key-required" → 401
  • Closed#49119auxiliary vision: api_key env var fallback skipped when base_url is set
  • Closed#53687Bug: auxiliary vision (and other tasks) get 401 when provider + base_url both set in config
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.07: 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
#20215, #21219, #55605, #59207
Closed issues
9
Attempts
11 PRs closed without merging
Fix link checked
6 Jul 2026

For your agent

The same facts, typed.