Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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=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(
Expand Down
12 changes: 10 additions & 2 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down
155 changes: 155 additions & 0 deletions tests/agent/test_auxiliary_named_custom_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "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)
Loading