From e7d4ade8cfe30c7af12048995baf5f2f0fd9e90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=A5=E6=A3=AE=E6=9C=A8?= <258443621+clovericbot@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:12:06 -0700 Subject: [PATCH] fix(anthropic): ignore stale non-Anthropic base_url across all resolution paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A config left with `provider: anthropic` but a leftover `base_url: https://openrouter.ai/api/v1` (e.g. after a provider switch) would route Anthropic OAuth/setup-token traffic to OpenRouter and 404. Add `_anthropic_base_url_override_ok()` and gate the three native-Anthropic resolution branches (pool, explicit, native) on it. The guard honors a configured `model.base_url` only when it plausibly speaks the Anthropic Messages protocol — official `*.anthropic.com` / `*.claude.com` hosts, Azure Foundry endpoints, and `/anthropic`-suffixed or Kimi `/coding` proxies — and falls back to `https://api.anthropic.com` otherwise. Aggregator URLs like openrouter.ai / api.openai.com are treated as stale. Reconstructed from @clovericbot's PR #3661 onto current main: the original patched one branch with an anthropic-only allow-list, which would have broken Azure-via-anthropic; widened to all three sites and made Azure/proxy-safe. --- hermes_cli/runtime_provider.py | 43 +++++++++++++ .../test_runtime_provider_resolution.py | 62 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index ef3df63b893..50a826ebbae 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -172,6 +172,43 @@ def _host_derived_api_key(base_url: str) -> str: return (_getenv(env_name, "") or "").strip() +def _anthropic_base_url_override_ok(base_url: str) -> bool: + """Decide whether a configured ``model.base_url`` may back native Anthropic. + + Native ``provider: anthropic`` resolution honors ``model.base_url`` so users + can point at Anthropic-compatible endpoints (official Anthropic/Claude hosts, + Azure Foundry, MiniMax/Zhipu/LiteLLM-style ``/anthropic`` proxies, Kimi's + ``/coding`` route). But a config can carry a *stale* non-Anthropic URL — e.g. + ``provider: anthropic`` left with ``base_url: https://openrouter.ai/api/v1`` + after a provider switch — which would route Anthropic OAuth/setup-token + traffic to an OpenAI-compatible aggregator and 404. Ignore those. + + Returns True only when the URL plausibly speaks the Anthropic Messages + protocol; otherwise the caller falls back to ``https://api.anthropic.com``. + """ + candidate = (base_url or "").strip() + if not candidate: + return False + + hostname = (base_url_hostname(candidate) or "").lower() + if not hostname: + return False + + # Official Anthropic / Claude hosts. + if hostname == "api.anthropic.com" or hostname.endswith(".anthropic.com") or hostname.endswith(".claude.com"): + return True + # Azure Foundry Anthropic endpoints (handled specially downstream). + if hostname.endswith(".azure.com"): + return True + # Anthropic-compatible proxies conventionally expose the native Messages + # protocol under a ``/anthropic`` suffix, and Kimi under ``/coding`` — same + # signal _detect_api_mode_for_url() uses to pick anthropic_messages. + if _detect_api_mode_for_url(candidate) == "anthropic_messages": + return True + # Bare api.kimi.com without the /coding path is not an Anthropic endpoint. + return False + + def _auto_detect_local_model(base_url: str) -> str: """Query a local server for its model name when only one model is loaded.""" if not base_url: @@ -344,6 +381,8 @@ def _resolve_runtime_from_pool_entry( cfg_base_url = "" if cfg_provider == "anthropic": cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + if not _anthropic_base_url_override_ok(cfg_base_url): + cfg_base_url = "" base_url = cfg_base_url or base_url or "https://api.anthropic.com" elif provider == "openrouter": base_url = base_url or OPENROUTER_BASE_URL @@ -1247,6 +1286,8 @@ def _resolve_explicit_runtime( cfg_base_url = "" if cfg_provider == "anthropic": cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + if not _anthropic_base_url_override_ok(cfg_base_url): + cfg_base_url = "" base_url = explicit_base_url or cfg_base_url or "https://api.anthropic.com" api_key = explicit_api_key if not api_key: @@ -1697,6 +1738,8 @@ def resolve_runtime_provider( cfg_base_url = "" if cfg_provider == "anthropic": cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") + if not _anthropic_base_url_override_ok(cfg_base_url): + cfg_base_url = "" base_url = cfg_base_url or "https://api.anthropic.com" # For Microsoft Foundry endpoints, use ANTHROPIC_API_KEY directly — diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index d0e41efad6b..8e64223a3cd 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -76,6 +76,68 @@ def test_resolve_runtime_provider_anthropic_pool_respects_config_base_url(monkey assert resolved["base_url"] == "https://proxy.example.com/anthropic" +def test_resolve_runtime_provider_anthropic_ignores_stale_aggregator_base_url(monkeypatch): + """A leftover OpenRouter base_url under provider: anthropic must not hijack + Anthropic OAuth traffic — fall back to the official Anthropic host.""" + + class _Entry: + access_token = "pool-token" + source = "manual" + base_url = "https://api.anthropic.com" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool()) + + for stale in ( + "https://openrouter.ai/api/v1", + "https://api.openai.com/v1", + ): + monkeypatch.setattr( + rp, + "_get_model_config", + lambda stale=stale: {"provider": "anthropic", "base_url": stale}, + ) + resolved = rp.resolve_runtime_provider(requested="anthropic") + assert resolved["provider"] == "anthropic" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://api.anthropic.com", stale + + +def test_resolve_runtime_provider_anthropic_keeps_azure_base_url(monkeypatch): + """Azure Foundry Anthropic endpoints are not anthropic.com hosts but are a + legitimate override — they must survive the stale-URL guard.""" + + class _Entry: + access_token = "pool-token" + source = "manual" + base_url = "https://api.anthropic.com" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool()) + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "anthropic", "base_url": "https://myhost.azure.com/anthropic"}, + ) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + assert resolved["base_url"] == "https://myhost.azure.com/anthropic" + + def test_resolve_runtime_provider_anthropic_explicit_override_skips_pool(monkeypatch): def _unexpected_pool(provider): raise AssertionError(f"load_pool should not be called for {provider}")