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
- 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
Open PRs0
Fixes on record
-
#15059 fix(aux-client): honor api_mode: anthropic_messages for named custom providers
files:
agent/auxiliary_client.py,hermes_cli/runtime_provider.py,tests/agent/test_auxiliary_named_custom_providers.pyShow 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
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.00means 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 LabAt 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.