NousResearch/hermes-agent

Named custom providers ignoring explicit api_mode setting

Cluster 13051. Data as of

Nothing open on GitHub. Fix merged in #15059 on 24 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #15059. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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).

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #15059 fix(aux-client): honor api_mode: anthropic_messages for named custom providers

    merged to main

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

    Show the diff
    • agent/auxiliary_client.py
      @@ -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)
      @@ -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
      @@ -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:
      @@ -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
      @@ -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)

2 PRs closed without merging

From the close comments: done elsewhere 2

  • #13087 fix(cli): preserve api_mode for named custom providers in runtime resolution (#13051) · done elsewhere → b292872, #15059
  • #14087 fix(runtime-provider): preserve named custom api modes · done elsewhere → b292872, #15059, #13087, #13051
Closed issues · 1
  • Closed#13051[Bug]: named custom providers under providers: ignore explicit api_mode during runtime resolution
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#15059
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
24 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.