fix(aux-client): honor api_mode: anthropic_messages for named custom providers (#15059)
Auxiliary tasks (session_search, flush_memories, approvals, compression,
vision, etc.) that route to a named custom provider declared under
config.yaml 'providers:' with 'api_mode: anthropic_messages' were
silently building a plain OpenAI client and POSTing to
{base_url}/chat/completions, which returns 404 on Anthropic-compatible
gateways that only expose /v1/messages.
Two gaps caused this:
1. hermes_cli/runtime_provider.py::_get_named_custom_provider — the
providers-dict branch (new-style) returned only name/base_url/api_key/
model and dropped api_mode. The legacy custom_providers-list branch
already propagated it correctly. The dict branch now parses and
returns api_mode via _parse_api_mode() in both match paths.
2. agent/auxiliary_client.py::resolve_provider_client — the named
custom provider block at ~L1740 ignored custom_entry['api_mode']
and unconditionally built an OpenAI client (only wrapping for
Codex/Responses). It now mirrors _try_custom_endpoint()'s three-way
dispatch: anthropic_messages → AnthropicAuxiliaryClient (async wrapped
in AsyncAnthropicAuxiliaryClient), codex_responses → CodexAuxiliaryClient,
otherwise plain OpenAI. An explicit task-level api_mode override
still wins over the provider entry's declared api_mode.
Fixes #15033
Tests: tests/agent/test_auxiliary_named_custom_providers.py gains a
TestProvidersDictApiModeAnthropicMessages class covering
- providers-dict preserves valid api_mode
- invalid api_mode values are dropped
- missing api_mode leaves the entry unchanged (no regression)
- resolve_provider_client returns (Async)AnthropicAuxiliaryClient for
api_mode=anthropic_messages
- full chain via get_text_auxiliary_client / get_async_text_auxiliary_client
with an auxiliary.<task> override
- providers without api_mode still use the OpenAI-wire path
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.<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": "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)
|
||||
|
||||
Reference in New Issue
Block a user