fix(auxiliary): honor /anthropic-suffixed gateway base_url on aux + fallback calls
`_try_anthropic()` applies the configured `model.base_url` only when `_is_anthropic_compatible_host()` trusts it, but that check accepted only the literal `api.anthropic.com` host. Anthropic-compatible gateways that expose the native Messages protocol under a `/anthropic` path suffix (MiniMax, Zhipu GLM, LiteLLM-style relays, self-hosted proxies) were rejected, so every auxiliary call (title generation, memory extraction, vision, reflection) and the `provider: anthropic` fallback chain discarded the configured base_url and fell back to `https://api.anthropic.com`. That diverges from the primary path, which already trusts the `/anthropic` suffix via `runtime_provider._detect_api_mode_for_url`, and fails outright when the gateway (not Anthropic) holds the credentials. Accept `/anthropic` and `/anthropic/v1` suffixed URLs in `_is_anthropic_compatible_host()`, matching the primary-path convention and `_wrap_if_needed`. A bare non-Anthropic base_url (e.g. `openrouter.ai/api/v1` left on `provider: anthropic`) still returns False, preserving the #52608 guard.
This commit is contained in:
@@ -1341,13 +1341,31 @@ _ANTHROPIC_COMPATIBLE_HOSTS = frozenset({
|
||||
|
||||
|
||||
def _is_anthropic_compatible_host(url: str) -> bool:
|
||||
"""Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls."""
|
||||
"""Return True if ``url`` is an Anthropic endpoint we trust for aux calls.
|
||||
|
||||
Trust the native Anthropic hosts, plus Anthropic-compatible gateways that
|
||||
expose the native Messages protocol under a ``/anthropic`` path suffix
|
||||
(MiniMax, Zhipu GLM, LiteLLM-style relays, self-hosted proxies). That suffix
|
||||
is the same convention ``runtime_provider._detect_api_mode_for_url`` uses to
|
||||
route ``provider: anthropic`` on the primary path, and ``_wrap_if_needed``
|
||||
uses to pick the Anthropic wire transport — without this, ``_try_anthropic``
|
||||
discards a configured ``model.base_url`` for auxiliary and fallback calls and
|
||||
forces ``https://api.anthropic.com``, so those calls diverge from the main
|
||||
agent's endpoint (and fail when the gateway, not Anthropic, holds auth).
|
||||
|
||||
A bare non-Anthropic base_url (e.g. a stale ``openrouter.ai/api/v1`` left on
|
||||
``provider: anthropic``) still returns False — the guard #52608 added.
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
host = (urlparse(url).hostname or "").strip().lower().rstrip(".")
|
||||
return host in _ANTHROPIC_COMPATIBLE_HOSTS
|
||||
parsed = urlparse(url)
|
||||
host = (parsed.hostname or "").strip().lower().rstrip(".")
|
||||
if host in _ANTHROPIC_COMPATIBLE_HOSTS:
|
||||
return True
|
||||
path = (parsed.path or "").rstrip("/").lower()
|
||||
return path.endswith("/anthropic") or path.endswith("/anthropic/v1")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -123,3 +123,115 @@ class TestTryAnthropicBaseUrlHostValidation:
|
||||
)
|
||||
|
||||
|
||||
def test_empty_base_url_falls_back_to_default(self, tmp_path, monkeypatch):
|
||||
"""Empty model.base_url must not crash and must fall back to default."""
|
||||
import yaml
|
||||
from agent.auxiliary_client import _try_anthropic
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"base_url": "",
|
||||
}
|
||||
}))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
return_value="***",
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client"
|
||||
) as mock_build,
|
||||
):
|
||||
mock_build.return_value = MagicMock()
|
||||
client, _model = _try_anthropic()
|
||||
|
||||
assert client is not None
|
||||
actual = _extract_base_url_passed_to_build(mock_build)
|
||||
assert actual == "https://api.anthropic.com"
|
||||
|
||||
def test_anthropic_suffix_gateway_base_url_is_applied(self, tmp_path, monkeypatch):
|
||||
"""A gateway exposing the Messages protocol under a ``/anthropic`` suffix
|
||||
must be honored — the same convention the primary path already trusts —
|
||||
so auxiliary/fallback calls hit the configured endpoint, not the default."""
|
||||
import yaml
|
||||
from agent.auxiliary_client import _try_anthropic
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"base_url": "https://gateway.example.com/anthropic",
|
||||
}
|
||||
}))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
return_value="***",
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client"
|
||||
) as mock_build,
|
||||
):
|
||||
mock_build.return_value = MagicMock()
|
||||
client, _model = _try_anthropic()
|
||||
|
||||
assert client is not None
|
||||
actual = _extract_base_url_passed_to_build(mock_build)
|
||||
assert actual == "https://gateway.example.com/anthropic", (
|
||||
f"/anthropic-suffixed gateway base_url must be applied. Got: {actual!r}"
|
||||
)
|
||||
|
||||
def test_anthropic_suffix_host_check_direct(self):
|
||||
"""Unit-level: the host check trusts native hosts and /anthropic gateways,
|
||||
and still rejects a bare non-Anthropic host (the #52608 guard)."""
|
||||
from agent.auxiliary_client import _is_anthropic_compatible_host as ok
|
||||
assert ok("https://api.anthropic.com") is True
|
||||
assert ok("https://gateway.example.com/anthropic") is True
|
||||
assert ok("http://127.0.0.1:8080/anthropic/v1") is True
|
||||
assert ok("https://openrouter.ai/api/v1") is False
|
||||
assert ok("https://api.openai.com/v1") is False
|
||||
assert ok("") is False
|
||||
|
||||
def test_anthropic_host_with_path_is_preserved(self, tmp_path, monkeypatch):
|
||||
"""api.anthropic.com with a path suffix must still pass the host check."""
|
||||
import yaml
|
||||
from agent.auxiliary_client import _try_anthropic
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"base_url": "https://api.anthropic.com/v1/messages",
|
||||
}
|
||||
}))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._select_pool_entry", return_value=(False, None)
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
return_value="***",
|
||||
),
|
||||
patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client"
|
||||
) as mock_build,
|
||||
):
|
||||
mock_build.return_value = MagicMock()
|
||||
client, _model = _try_anthropic()
|
||||
|
||||
assert client is not None
|
||||
actual = _extract_base_url_passed_to_build(mock_build)
|
||||
assert actual == "https://api.anthropic.com/v1/messages", (
|
||||
f"Anthropic host with path must be preserved. Got: {actual!r}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user