# NousResearch/hermes-agent · Auxiliary task API key resolution and credential lookup

## Open now

- 1 issue, 3 PRs open on GitHub.
- #26879: \[Bug\] auxiliary task provider identity lost when base_url + api_key are both set
- PR #16727: fix(auxiliary): resolve named provider credentials when base_url is set
- PR #26909: fix(auxiliary): preserve named provider on custom endpoint
- PR #106916: fix(agent): lift named custom provider key when a task base_url flattens it to bare custom

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.

## TL;DR

- 28 member(s): 1 open issue(s), 9 closed issue(s), 3 open PR(s), 4 merged PR(s), 11 PR(s) closed without merging.
- A fix was merged to main in PR #20215. Verify it addresses your case.
- **Recommended:** read PR #20215 (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: `partially_resolved` · deciding PR(s): `pr-20215,pr-59207`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 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.

## Open related issues

- #26879: \[Bug\] auxiliary task provider identity lost when base_url + api_key are both set

## Closed / duplicate issues

- #9318: \[Bug\]: Auxiliary client falls back to "no-key-required" when per-task custom base_url is set but api_key is empty
- #16290: \[Bug\]: auxiliary.vision with provider=zai and custom base_url fails to auto-resolve ZAI_API_KEY
- #16829: auxiliary.{task} with provider:auto + base_url set falls into 'custom' path with empty api_key, returns 401
- #21121: \[Bug\]: auxiliary "use as" in dashboard copies base_url but omits api_key, causing 401 on authenticated custom endpoints
- #34651: \[Bug\]: auxiliary vision returns 401 when provider has custom base_url — API key resolves to no-key-required
- #39599: auxiliary vision: explicit base_url routes through generic "custom" branch, leaking main model name + OPENAI_API_KEY to the configured backend (Gemini)
- #47741: Vision auxiliary with base_url set but no api_key → "no-key-required" → 401
- #49119: auxiliary vision: api_key env var fallback skipped when base_url is set
- #53687: Bug: auxiliary vision (and other tasks) get 401 when provider + base_url both set in config

## Prior & competing PRs

- **#16389: fix(auxiliary.vision): keep named-provider credential pool with custom base_url**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `6cf7a9e330cadabd2a0b7ae21f25dc400fc3aa63`, `#16290`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#20215: fix(auxiliary): avoid locking into custom path when api_key is empty (salvage #16944)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/auxiliary_client.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/credential_pool.py`, `hermes_cli/runtime_provider.py`, `tests/agent/test_credential_pool.py`
  - fix hunks:
  - `agent/credential_pool.py`

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

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

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

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

    ```diff
    @@ -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"))
    ```
- **#21286: fix(agent): inherit model.api_key when auxiliary base_url matches main endpoint**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#20139`, `#17760`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#55605: fix(aux): preserve provider identity for resolved endpoints**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/auxiliary_client.py`, `tests/agent/test_auxiliary_client.py`, `tests/run_agent/test_moa_loop_mode.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

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

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

    ```diff
    @@ -31,6 +31,7 @@
         _OPENROUTER_MODEL,
         OPENROUTER_BASE_URL,
         _resolve_auto,
    +    _resolve_task_provider_model,
         _resolve_xai_oauth_for_aux,
         _CodexCompletionsAdapter,
     )
    ```

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

    ```diff
    @@ -1,6 +1,8 @@
     from types import SimpleNamespace
     from unittest.mock import MagicMock
     
    +import pytest
    +
     from run_agent import AIAgent
     
     
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/auxiliary_client.py`, `tests/agent/test_auxiliary_client.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

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

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

    ```diff
    @@ -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"
    ```
- **#9386: Fix custom provider API key precedence**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#21219`, `#21219`
- **#16308: fix(aux-client): resolve named-provider credential when base_url overrides endpoint (#16290)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#16727: fix(auxiliary): resolve named provider credentials when base_url is set**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `6cf7a9e330ca`, `#16290`, `#15275`, `#16290`
  - files: `.gitignore`, `agent/auxiliary_client.py`, `model_tools.py`, `tests/agent/test_vision_provider_base_url_credentials.py`, `tests/tui_gateway/test_slash_worker_mcp.py`, `tools/mcp_tool.py`
- **#16944: fix(auxiliary): avoid locking into custom path when api_key is empty**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#9318`, `#16829`
- **#16950: fix(auxiliary_client): require api_key for custom provider path**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#16944`, `#9318`, `#16308`
- **#20139: fix(auxiliary): pass cfg_base_url and cfg_api_key when resolving task provider**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#26909: fix(auxiliary): preserve named provider on custom endpoint**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#9288`, `#10823`, `#16389`, `#14718`, `#16727`
  - files: `agent/auxiliary_client.py`, `tests/agent/test_vision_resolved_args.py`
- **#34652: fix(auxiliary): preserve named provider identity when base_url is passed to _resolve_task_provider_model**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#34651`, `#16290`, `#26879`, `#16389`, `#16727`, `#26909`
- **#49131: fix(agent): preserve vision provider for base_url fallback**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#53688: fix(auxiliary): preserve provider when base_url is passed explicitly**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a8841e2a6`, `#55605`, `#39732`
- **#55911: fix(auxiliary): inherit model.api_key for custom endpoint when per-task key is empty (#9318)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#59207`
- **#106916: fix(agent): lift named custom provider key when a task base_url flattens it to bare custom**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#34651`, `#96232`, `#34651`, `#96232`
  - files: `agent/auxiliary_client.py`, `hermes_cli/runtime_provider.py`, `hermes_cli/runtime_provider_custom.py`, `tests/agent/test_aux_custom_base_url_credential.py`

## Fix status

- Merged to main in PR #20215 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-06`.
- Subsystem: `agent`
