diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e812a337f..f470e142a 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1736,7 +1736,7 @@ def resolve_provider_client( "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 resolve_provider_client( 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=custom_key, 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=custom_key, 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=custom_key, 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( diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 922946e2a..4e04f75d7 100644 --- a/hermes_cli/runtime_provider.py +++ b/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": resolved_api_key, "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": resolved_api_key, "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") diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 437a6c400..0f307d57f 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -252,3 +252,158 @@ class TestVisionPathApiMode: 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": "local-key", + "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..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": "local-key", + "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)