diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index aa4b59292f..6a0df6c894 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 diff --git a/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py b/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py index 6be1bde86f..478a366078 100644 --- a/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py +++ b/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py @@ -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}" + )