{"repo":"NousResearch/hermes-agent","public_id":"13051","display_label":"Named custom providers ignoring explicit api_mode setting","summary":"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).","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15059,"merged_at":"2026-04-24T10:10:30+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-24T10:10:30+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":13051,"pr_number":15059,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-24T10:10:30+00:00"},{"issue_number":15033,"pr_number":15059,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-24T10:10:30+00:00"}],"members":[{"kind":"issue","number":13051,"title":"[Bug]: named custom providers under providers: ignore explicit api_mode during runtime resolution","url":null,"state":"closed"},{"kind":"pr","number":13087,"title":"fix(cli): preserve api_mode for named custom providers in runtime resolution (#13051)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b29287258","number":0},{"kind":"pr","value":"#15059","number":15059}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14087,"title":"fix(runtime-provider): preserve named custom api modes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b29287258","number":0},{"kind":"pr","value":"#15059","number":15059},{"kind":"pr","value":"#13087","number":13087},{"kind":"pr","value":"#13051","number":13051}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15059,"title":"fix(aux-client): honor api_mode: anthropic_messages for named custom providers","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","hermes_cli/runtime_provider.py","tests/agent/test_auxiliary_named_custom_providers.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1736,7 +1736,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n                        \"but no endpoint credentials found\")\n         return None, None\n \n-    # ── Named custom providers (config.yaml custom_providers list) ───\n+    # ── Named custom providers (config.yaml providers dict / custom_providers list) ───\n     try:\n         from hermes_cli.runtime_provider import _get_named_custom_provider\n         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 = \"\"):\n             if not custom_key and custom_key_env:\n                 custom_key = os.getenv(custom_key_env, \"\").strip()\n             custom_key = custom_key or \"no-key-required\"\n+            # An explicit per-task api_mode override (from _resolve_task_provider_model)\n+            # wins; otherwise fall back to what the provider entry declared.\n+            entry_api_mode = (api_mode or custom_entry.get(\"api_mode\") or \"\").strip()\n             if custom_base:\n                 final_model = _normalize_resolved_model(\n                     model or custom_entry.get(\"model\") or _read_main_model() or \"gpt-4o-mini\",\n                     provider,\n                 )\n-                client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n-                client = _wrap_if_needed(client, final_model, custom_base)\n                 logger.debug(\n-                    \"resolve_provider_client: named custom provider %r (%s)\",\n-                    provider, final_model)\n+                    \"resolve_provider_client: named custom provider %r (%s, api_mode=%s)\",\n+                    provider, final_model, entry_api_mode or \"chat_completions\")\n+                # anthropic_messages: route through the Anthropic Messages API\n+                # via AnthropicAuxiliaryClient. Mirrors the anonymous-custom\n+                # branch in _try_custom_endpoint(). See #15033.\n+                if entry_api_mode == \"anthropic_messages\":\n+                    try:\n+                        from agent.anthropic_adapter import build_anthropic_client\n+                        real_client = build_anthropic_client(custom_key, custom_base)\n+                    except ImportError:\n+                        logger.warning(\n+                            \"Named custom provider %r declares api_mode=\"\n+                            \"anthropic_messages but the anthropic SDK is not \"\n+                            \"installed — falling back to OpenAI-wire.\",\n+                            provider,\n+                        )\n+                        client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n+                        return (_to_async_client(client, final_model) if async_mode\n+                                else (client, final_model))\n+                    sync_anthropic = AnthropicAuxiliaryClient(\n+                        real_client, final_model, custom_key, custom_base, is_oauth=False,\n+                    )\n+                    if async_mode:\n+                        return AsyncAnthropicAuxiliaryClient(sync_anthropic), final_model\n+                    return sync_anthropic, final_model\n+                client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n+                # codex_responses or inherited auto-detect (via _wrap_if_needed).\n+                # _wrap_if_needed reads the closed-over `api_mode` (the task-level\n+                # override). Named-provider entry api_mode=codex_responses also\n+                # flows through here.\n+                if entry_api_mode == \"codex_responses\" and not isinstance(\n+                    client, CodexAuxiliaryClient\n+                ):\n+                    client = CodexAuxiliaryClient(client, final_model)\n+                else:\n+                    client = _wrap_if_needed(client, final_model, custom_base)\n                 return (_to_async_client(client, final_model) if async_mode\n                         else (client, final_model))\n             logger.warning("]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -323,12 +323,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n                 # Found match by provider key\n                 base_url = entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n                 if base_url:\n-                    return {\n+                    result = {\n                         \"name\": entry.get(\"name\", ep_name),\n                         \"base_url\": base_url.strip(),\n                         \"api_key\": [redacted:secret-assignment]\n                         \"model\": entry.get(\"default_model\", \"\"),\n                     }\n+                    api_mode = _parse_api_mode(entry.get(\"api_mode\"))\n+                    if api_mode:\n+                        result[\"api_mode\"] = api_mode\n+                    return result\n             # Also check the 'name' field if present\n             display_name = entry.get(\"name\", \"\")\n             if display_name:","@@ -337,12 +341,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n                     # Found match by display name\n                     base_url = entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n                     if base_url:\n-                        return {\n+                        result = {\n                             \"name\": display_name,\n                             \"base_url\": base_url.strip(),\n                             \"api_key\": [redacted:secret-assignment]\n                             \"model\": entry.get(\"default_model\", \"\"),\n                         }\n+                        api_mode = _parse_api_mode(entry.get(\"api_mode\"))\n+                        if api_mode:\n+                            result[\"api_mode\"] = api_mode\n+                        return result\n \n     # Fall back to custom_providers: list (legacy format)\n     custom_providers = config.get(\"custom_providers\")"]},{"file":"tests/agent/test_auxiliary_named_custom_providers.py","hunks":["@@ -252,3 +252,158 @@ def test_explicit_provider_passes_api_mode(self, tmp_path):\n         mock_gcc.assert_called_once()\n         _, kwargs = mock_gcc.call_args\n         assert kwargs.get(\"api_mode\") == \"chat_completions\"\n+\n+\n+class TestProvidersDictApiModeAnthropicMessages:\n+    \"\"\"Regression guard for #15033.\n+\n+    Named providers declared under the ``providers:`` dict with\n+    ``api_mode: anthropic_messages`` must route auxiliary calls through\n+    the Anthropic Messages API (via AnthropicAuxiliaryClient), not\n+    through an OpenAI chat-completions client.\n+\n+    The bug had two halves: the providers-dict branch of\n+    ``_get_named_custom_provider`` dropped the ``api_mode`` field, and\n+    ``resolve_provider_client``'s named-custom branch never read it.\n+    \"\"\"\n+\n+    def test_providers_dict_propagates_api_mode(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"MYRELAY_API_KEY\", \"sk-test\")\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"myrelay\": {\n+                    \"name\": \"myrelay\",\n+                    \"base_url\": \"https://example-relay.test/anthropic\",\n+                    \"key_env\": \"MYRELAY_API_KEY\",\n+                    \"api_mode\": \"anthropic_messages\",\n+                    \"default_model\": \"claude-opus-4-7\",\n+                },\n+            },\n+        })\n+        from hermes_cli.runtime_provider import _get_named_custom_provider\n+        entry = _get_named_custom_provider(\"myrelay\")\n+        assert entry is not None\n+        assert entry.get(\"api_mode\") == \"anthropic_messages\"\n+        assert entry.get(\"base_url\") == \"https://example-relay.test/anthropic\"\n+        assert entry.get(\"api_key\") == \"sk-test\"\n+\n+    def test_providers_dict_invalid_api_mode_is_dropped(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"weird\": {\n+                    \"name\": \"weird\",\n+                    \"base_url\": \"https://example.test\",\n+                    \"api_mode\": \"bogus_nonsense\",\n+                    \"default_model\": \"x\",\n+                },\n+            },\n+        })\n+        from hermes_cli.runtime_provider import _get_named_custom_provider\n+        entry = _get_named_custom_provider(\"weird\")\n+        assert entry is not None\n+        assert \"api_mode\" not in entry\n+\n+    def test_providers_dict_without_api_mode_is_unchanged(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"localchat\": {\n+                    \"name\": \"localchat\",\n+                    \"base_url\": \"http://127.0.0.1:1234/v1\",\n+                    \"api_key\": \"[redacted:secret-assignment]\",\n+                    \"default_model\": \"llama-3\",\n+                },\n+            },\n+        })\n+        from hermes_cli.runtime_provider import _get_named_custom_provider\n+        entry = _get_named_custom_provider(\"localchat\")\n+        assert entry is not None\n+        assert \"api_mode\" not in entry\n+\n+    def test_resolve_provider_client_returns_anthropic_client(self, tmp_path, monkeypatch):\n+        \"\"\"Named custom provider with api_mode=anthropic_messages must\n+        route through AnthropicAuxiliaryClient.\"\"\"\n+        monkeypatch.setenv(\"MYRELAY_API_KEY\", \"sk-test\")\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"myrelay\": {\n+                    \"name\": \"myrelay\",\n+                    \"base_url\": \"https://example-relay.test/anthropic\",\n+                    \"key_env\": \"MYRELAY_API_KEY\",\n+                    \"api_mode\": \"anthropic_messages\",\n+                    \"default_model\": \"claude-opus-4-7\",\n+                },\n+            },\n+        })\n+        from agent.auxiliary_client import (\n+            resolve_provider_client,\n+            AnthropicAuxiliaryClient,\n+            AsyncAnthropicAuxiliaryClient,\n+        )\n+        sync_client, sync_model = resolve_provider_client(\"myrelay\", async_mode=False)\n+        assert isinstance(sync_client, AnthropicAuxiliaryClient), (\n+            f\"expected AnthropicAuxiliaryClient, got {type(sync_client).__name__}\"\n+        )\n+        assert sync_model == \"claude-opus-4-7\"\n+\n+        async_client, async_model = resolve_provider_client(\"myrelay\", async_mode=True)\n+        assert isinstance(async_client, AsyncAnthropicAuxiliaryClient), (\n+            f\"expected AsyncAnthropicAuxiliaryClient, got {type(async_client).__name__}\"\n+        )\n+        assert async_model == \"claude-opus-4-7\"\n+\n+    def test_aux_task_override_routes_named_provider_to_anthropic(self, tmp_path, monkeypatch):\n+        \"\"\"The full chain: auxiliary.<task>.provider: myrelay with\n+        api_mode anthropic_messages must produce an Anthropic client.\"\"\"\n+        monkeypatch.setenv(\"MYRELAY_API_KEY\", \"sk-test\")\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"myrelay\": {\n+                    \"name\": \"myrelay\",\n+                    \"base_url\": \"https://example-relay.test/anthropic\",\n+                    \"key_env\": \"MYRELAY_API_KEY\",\n+                    \"api_mode\": \"anthropic_messages\",\n+                    \"default_model\": \"claude-opus-4-7\",\n+                },\n+            },\n+            \"auxiliary\": {\n+                \"flush_memories\": {\n+                    \"provider\": \"myrelay\",\n+                    \"model\": \"claude-sonnet-4.6\",\n+                },\n+            },\n+            \"model\": {\"provider\": \"openrouter\", \"default\": \"anthropic/claude-sonnet-4.6\"},\n+        })\n+        from agent.auxiliary_client import (\n+            get_async_text_auxiliary_client,\n+            get_text_auxiliary_client,\n+            AnthropicAuxiliaryClient,\n+            AsyncAnthropicAuxiliaryClient,\n+        )\n+        async_client, async_model = get_async_text_auxiliary_client(\"flush_memories\")\n+        assert isinstance(async_client, AsyncAnthropicAuxiliaryClient)\n+        assert async_model == \"claude-sonnet-4.6\"\n+\n+        sync_client, sync_model = get_text_auxiliary_client(\"flush_memories\")\n+        assert isinstance(sync_client, AnthropicAuxiliaryClient)\n+        assert sync_model == \"claude-sonnet-4.6\"\n+\n+    def test_provider_without_api_mode_still_uses_openai(self, tmp_path):\n+        \"\"\"Named providers that don't declare api_mode should still go\n+        through the plain OpenAI-wire path (no regression).\"\"\"\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"localchat\": {\n+                    \"name\": \"localchat\",\n+                    \"base_url\": \"http://127.0.0.1:1234/v1\",\n+                    \"api_key\": \"[redacted:secret-assignment]\",\n+                    \"default_model\": \"llama-3\",\n+                },\n+            },\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        from openai import OpenAI, AsyncOpenAI\n+        sync_client, _ = resolve_provider_client(\"localchat\", async_mode=False)\n+        # sync returns the raw OpenAI client\n+        assert isinstance(sync_client, OpenAI)\n+        async_client, _ = resolve_provider_client(\"localchat\", async_mode=True)\n+        assert isinstance(async_client, AsyncOpenAI)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}