# NousResearch/hermes-agent · Named custom providers ignoring explicit api_mode setting

## Open now

- Nothing open on GitHub.

One bug report and three pull requests concern named custom providers under \`providers:\` not honoring an explicit \`api_mode\` (including \`anthropic_messages\`) during runtime or auxiliary-client resolution. The pull requests appear to propose preserving that setting in different code areas (CLI, runtime-provider, aux-client).

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #15059. Verify it addresses your case.
- **Recommended:** read PR #15059 (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: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

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

## Closed / duplicate issues

- #13051: \[Bug\]: named custom providers under providers: ignore explicit api_mode during runtime resolution

## Prior & competing PRs

- **#15059: fix(aux-client): honor api_mode: anthropic_messages for named custom providers**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/auxiliary_client.py`, `hermes_cli/runtime_provider.py`, `tests/agent/test_auxiliary_named_custom_providers.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -1736,7 +1736,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
                            "but no endpoint credentials found")
             return None, None
     
    -    # ── Named custom providers (config.yaml custom_providers list) ───
    +    # ── Named custom providers (config.yaml providers dict / custom_providers list) ───
         try:
             from hermes_cli.runtime_provider import _get_named_custom_provider
             custom_entry = _get_named_custom_provider(provider)
    ```

    ```diff
    @@ -1747,16 +1747,51 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
                 if not custom_key and custom_key_env:
                     custom_key = os.getenv(custom_key_env, "").strip()
                 custom_key = custom_key or "no-key-required"
    +            # An explicit per-task api_mode override (from _resolve_task_provider_model)
    +            # wins; otherwise fall back to what the provider entry declared.
    +            entry_api_mode = (api_mode or custom_entry.get("api_mode") or "").strip()
                 if custom_base:
                     final_model = _normalize_resolved_model(
                         model or custom_entry.get("model") or _read_main_model() or "gpt-4o-mini",
                         provider,
                     )
    -                client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)
    -                client = _wrap_if_needed(client, final_model, custom_base)
                     logger.debug(
    -                    "resolve_provider_client: named custom provider %r (%s)",
    -                    provider, final_model)
    +                    "resolve_provider_client: named custom provider %r (%s, api_mode=%s)",
    +                    provider, final_model, entry_api_mode or "chat_completions")
    +                # anthropic_messages: route through the Anthropic Messages API
    +                # via AnthropicAuxiliaryClient. Mirrors the anonymous-custom
    +                # branch in _try_custom_endpoint(). See #15033.
    +                if entry_api_mode == "anthropic_messages":
    +                    try:
    +                        from agent.anthropic_adapter import build_anthropic_client
    +                        real_client = build_anthropic_client(custom_key, custom_base)
    +                    except ImportError:
    +                        logger.warning(
    +                            "Named custom provider %r declares api_mode="
    +                            "anthropic_messages but the anthropic SDK is not "
    +                            "installed — falling back to OpenAI-wire.",
    +                            provider,
    +                        )
    +                        client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)
    +                        return (_to_async_client(client, final_model) if async_mode
    +                                else (client, final_model))
    +                    sync_anthropic = AnthropicAuxiliaryClient(
    +                        real_client, final_model, custom_key, custom_base, is_oauth=False,
    +                    )
    +                    if async_mode:
    +                        return AsyncAnthropicAuxiliaryClient(sync_anthropic), final_model
    +                    return sync_anthropic, final_model
    +                client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)
    +                # codex_responses or inherited auto-detect (via _wrap_if_needed).
    +                # _wrap_if_needed reads the closed-over `api_mode` (the task-level
    +                # override). Named-provider entry api_mode=codex_responses also
    +                # flows through here.
    +                if entry_api_mode == "codex_responses" and not isinstance(
    +                    client, CodexAuxiliaryClient
    +                ):
    +                    client = CodexAuxiliaryClient(client, final_model)
    +                else:
    +                    client = _wrap_if_needed(client, final_model, custom_base)
                     return (_to_async_client(client, final_model) if async_mode
                             else (client, final_model))
                 logger.warning(
    ```
  - `hermes_cli/runtime_provider.py`

    ```diff
    @@ -323,12 +323,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
                     # Found match by provider key
                     base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or ""
                     if base_url:
    -                    return {
    +                    result = {
                             "name": entry.get("name", ep_name),
                             "base_url": base_url.strip(),
                             "api_key": [redacted:secret-assignment]
                             "model": entry.get("default_model", ""),
                         }
    +                    api_mode = _parse_api_mode(entry.get("api_mode"))
    +                    if api_mode:
    +                        result["api_mode"] = api_mode
    +                    return result
                 # Also check the 'name' field if present
                 display_name = entry.get("name", "")
                 if display_name:
    ```

    ```diff
    @@ -337,12 +341,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
                         # Found match by display name
                         base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or ""
                         if base_url:
    -                        return {
    +                        result = {
                                 "name": display_name,
                                 "base_url": base_url.strip(),
                                 "api_key": [redacted:secret-assignment]
                                 "model": entry.get("default_model", ""),
                             }
    +                        api_mode = _parse_api_mode(entry.get("api_mode"))
    +                        if api_mode:
    +                            result["api_mode"] = api_mode
    +                        return result
     
         # Fall back to custom_providers: list (legacy format)
         custom_providers = config.get("custom_providers")
    ```
  - `tests/agent/test_auxiliary_named_custom_providers.py`

    ```diff
    @@ -252,3 +252,158 @@ def test_explicit_provider_passes_api_mode(self, tmp_path):
             mock_gcc.assert_called_once()
             _, kwargs = mock_gcc.call_args
             assert kwargs.get("api_mode") == "chat_completions"
    +
    +
    +class TestProvidersDictApiModeAnthropicMessages:
    +    """Regression guard for #15033.
    +
    +    Named providers declared under the ``providers:`` dict with
    +    ``api_mode: anthropic_messages`` must route auxiliary calls through
    +    the Anthropic Messages API (via AnthropicAuxiliaryClient), not
    +    through an OpenAI chat-completions client.
    +
    +    The bug had two halves: the providers-dict branch of
    +    ``_get_named_custom_provider`` dropped the ``api_mode`` field, and
    +    ``resolve_provider_client``'s named-custom branch never read it.
    +    """
    +
    +    def test_providers_dict_propagates_api_mode(self, tmp_path, monkeypatch):
    +        monkeypatch.setenv("MYRELAY_API_KEY", "sk-test")
    +        _write_config(tmp_path, {
    +            "providers": {
    +                "myrelay": {
    +                    "name": "myrelay",
    +                    "base_url": "https://example-relay.test/anthropic",
    +                    "key_env": "MYRELAY_API_KEY",
    +                    "api_mode": "anthropic_messages",
    +                    "default_model": "claude-opus-4-7",
    +                },
    +            },
    +        })
    +        from hermes_cli.runtime_provider import _get_named_custom_provider
    +        entry = _get_named_custom_provider("myrelay")
    +        assert entry is not None
    +        assert entry.get("api_mode") == "anthropic_messages"
    +        assert entry.get("base_url") == "https://example-relay.test/anthropic"
    +        assert entry.get("api_key") == "sk-test"
    +
    +    def test_providers_dict_invalid_api_mode_is_dropped(self, tmp_path):
    +        _write_config(tmp_path, {
    +            "providers": {
    +                "weird": {
    +                    "name": "weird",
    +                    "base_url": "https://example.test",
    +                    "api_mode": "bogus_nonsense",
    +                    "default_model": "x",
    +                },
    +            },
    +        })
    +        from hermes_cli.runtime_provider import _get_named_custom_provider
    +        entry = _get_named_custom_provider("weird")
    +        assert entry is not None
    +        assert "api_mode" not in entry
    +
    +    def test_providers_dict_without_api_mode_is_unchanged(self, tmp_path):
    +        _write_config(tmp_path, {
    +            "providers": {
    +                "localchat": {
    +                    "name": "localchat",
    +                    "base_url": "http://127.0.0.1:1234/v1",
    +                    "api_key": "[redacted:secret-assignment]",
    +                    "default_model": "llama-3",
    +                },
    +            },
    +        })
    +        from hermes_cli.runtime_provider import _get_named_custom_provider
    +        entry = _get_named_custom_provider("localchat")
    +        assert entry is not None
    +        assert "api_mode" not in entry
    +
    +    def test_resolve_provider_client_returns_anthropic_client(self, tmp_path, monkeypatch):
    +        """Named custom provider with api_mode=anthropic_messages must
    +        route through AnthropicAuxiliaryClient."""
    +        monkeypatch.setenv("MYRELAY_API_KEY", "sk-test")
    +        _write_config(tmp_path, {
    +            "providers": {
    +                "myrelay": {
    +                    "name": "myrelay",
    +                    "base_url": "https://example-relay.test/anthropic",
    +                    "key_env": "MYRELAY_API_KEY",
    +                    "api_mode": "anthropic_messages",
    +                    "default_model": "claude-opus-4-7",
    +                },
    +            },
    +        })
    +        from agent.auxiliary_client import (
    +            resolve_provider_client,
    +            AnthropicAuxiliaryClient,
    +            AsyncAnthropicAuxiliaryClient,
    +        )
    +        sync_client, sync_model = resolve_provider_client("myrelay", async_mode=False)
    +        assert isinstance(sync_client, AnthropicAuxiliaryClient), (
    +            f"expected AnthropicAuxiliaryClient, got {type(sync_client).__name__}"
    +        )
    +        assert sync_model == "claude-opus-4-7"
    +
    +        async_client, async_model = resolve_provider_client("myrelay", async_mode=True)
    +        assert isinstance(async_client, AsyncAnthropicAuxiliaryClient), (
    +            f"expected AsyncAnthropicAuxiliaryClient, got {type(async_client).__name__}"
    +        )
    +        assert async_model == "claude-opus-4-7"
    +
    +    def test_aux_task_override_routes_named_provider_to_anthropic(self, tmp_path, monkeypatch):
    +        """The full chain: auxiliary.<task>.provider: myrelay with
    +        api_mode anthropic_messages must produce an Anthropic client."""
    +        monkeypatch.setenv("MYRELAY_API_KEY", "sk-test")
    +        _write_config(tmp_path, {
    +            "providers": {
    +                "myrelay": {
    +                    "name": "myrelay",
    +                    "base_url": "https://example-relay.test/anthropic",
    +                    "key_env": "MYRELAY_API_KEY",
    +                    "api_mode": "anthropic_messages",
    +                    "default_model": "claude-opus-4-7",
    +                },
    +            },
    +            "auxiliary": {
    +                "flush_memories": {
    +                    "provider": "myrelay",
    +                    "model": "claude-sonnet-4.6",
    +                },
    +            },
    +            "model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"},
    +        })
    +        from agent.auxiliary_client import (
    +            get_async_text_auxiliary_client,
    +            get_text_auxiliary_client,
    +            AnthropicAuxiliaryClient,
    +            AsyncAnthropicAuxiliaryClient,
    +        )
    +        async_client, async_model = get_async_text_auxiliary_client("flush_memories")
    +        assert isinstance(async_client, AsyncAnthropicAuxiliaryClient)
    +        assert async_model == "claude-sonnet-4.6"
    +
    +        sync_client, sync_model = get_text_auxiliary_client("flush_memories")
    +        assert isinstance(sync_client, AnthropicAuxiliaryClient)
    +        assert sync_model == "claude-sonnet-4.6"
    +
    +    def test_provider_without_api_mode_still_uses_openai(self, tmp_path):
    +        """Named providers that don't declare api_mode should still go
    +        through the plain OpenAI-wire path (no regression)."""
    +        _write_config(tmp_path, {
    +            "providers": {
    +                "localchat": {
    +                    "name": "localchat",
    +                    "base_url": "http://127.0.0.1:1234/v1",
    +                    "api_key": "[redacted:secret-assignment]",
    +                    "default_model": "llama-3",
    +                },
    +            },
    +        })
    +        from agent.auxiliary_client import resolve_provider_client
    +        from openai import OpenAI, AsyncOpenAI
    +        sync_client, _ = resolve_provider_client("localchat", async_mode=False)
    +        # sync returns the raw OpenAI client
    +        assert isinstance(sync_client, OpenAI)
    +        async_client, _ = resolve_provider_client("localchat", async_mode=True)
    +        assert isinstance(async_client, AsyncOpenAI)
    ```
- **#13087: fix(cli): preserve api_mode for named custom providers in runtime resolution (#13051)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `b29287258`, `#15059`
- **#14087: fix(runtime-provider): preserve named custom api modes**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `b29287258`, `#15059`, `#13087`, `#13051`

## Fix status

- Merged to main in PR #15059 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-24`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
