diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 56228ac09241..8392a76a3f43 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1241,7 +1241,17 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool _fb_is_azure = agent._is_azure_openai_url(fb_base_url) if fb_provider == "openai-codex": fb_api_mode = "codex_responses" - elif fb_provider == "anthropic" or fb_base_url.rstrip("/").lower().endswith("/anthropic"): + elif ( + fb_provider == "anthropic" + or fb_base_url.rstrip("/").lower().endswith("/anthropic") + or base_url_hostname(fb_base_url) == "api.anthropic.com" + ): + # Custom providers (e.g. cron-anthropic) point at the native + # api.anthropic.com host with no "/anthropic" path suffix, so the + # name/suffix checks above miss them and they default to + # chat_completions → POST /v1/chat/completions → 404. Match the + # host the same way determine_api_mode() and _detect_api_mode_for_url() + # do on the primary path. (#32243, #49247) fb_api_mode = "anthropic_messages" elif _fb_is_azure: # Azure OpenAI serves gpt-5.x on /chat/completions — does NOT diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 7f4692b838cd..690c2c961024 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -20,6 +20,7 @@ DEFAULT_XAI_OAUTH_BASE_URL, PROVIDER_REGISTRY, _agent_key_is_usable, + _nous_inference_env_override, format_auth_error, resolve_provider, resolve_nous_runtime_credentials, @@ -92,6 +93,13 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: - Direct api.openai.com endpoints need the Responses API for GPT-5.x tool calls with reasoning (chat/completions returns 400). + - Direct api.anthropic.com endpoints must use the native Messages + API (``/v1/messages``). Anthropic also exposes an OpenAI-compat + ``/chat/completions`` shim on the same host, but Pro/Max OAuth + subscriptions are only billed against the native Messages route; + hitting the shim accounts against a separate "extra usage" pool + that is empty by default and surfaces as HTTP 400 "You're out of + extra usage." See issue #32243. - Third-party Anthropic-compatible gateways (MiniMax, Zhipu GLM, LiteLLM proxies, etc.) conventionally expose the native Anthropic protocol under a ``/anthropic`` suffix — treat those as @@ -107,6 +115,12 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: return "codex_responses" if hostname == "api.openai.com": return "codex_responses" + # Direct native Anthropic host: realign with providers.determine_api_mode, + # which already maps this host to anthropic_messages. The exact-hostname + # match rejects lookalike subdomains (api.anthropic.com.attacker.test) and + # path-segment spoofing (proxy.test/api.anthropic.com/v1). (#32243) + if hostname == "api.anthropic.com": + return "anthropic_messages" path = urlparse(normalized).path.rstrip("/") if path.endswith("/anthropic") or path.endswith("/anthropic/v1"): return "anthropic_messages" @@ -334,6 +348,17 @@ def _parse_api_mode(raw: Any) -> Optional[str]: return None +def _nous_inference_base_url_override() -> str: + """Return the trusted Nous runtime base URL override, if configured. + + Delegates to ``auth._nous_inference_env_override`` so every + ``NOUS_INFERENCE_BASE_URL`` read shares one normalization path + (trailing-slash stripping, blank → empty). The env source is trusted + and intentionally bypasses the network host allowlist there. + """ + return _nous_inference_env_override() or "" + + def _maybe_apply_codex_app_server_runtime( *, provider: str, @@ -412,6 +437,7 @@ def _resolve_runtime_from_pool_entry( api_mode = "codex_responses" elif provider == "nous": api_mode = "chat_completions" + base_url = _nous_inference_base_url_override() or base_url elif provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", "")) base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url @@ -1359,6 +1385,7 @@ def _resolve_explicit_runtime( state = auth_mod.get_provider_auth_state("nous") or {} base_url = ( explicit_base_url + or _nous_inference_base_url_override() or str(state.get("inference_base_url") or auth_mod.DEFAULT_NOUS_INFERENCE_URL).strip().rstrip("/") ) # Only use the agent_key compatibility field for inference when it diff --git a/scripts/release.py b/scripts/release.py index ec347f98aa28..27a6f0084cb2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,7 +45,6 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { - "5848605+itenev@users.noreply.github.com": "itenev", # PR #22753 salvage (asyncify model-context resolution in gateway message path so blocking requests.get can't starve Discord heartbeats) "290873280+rrevenanttt@users.noreply.github.com": "rrevenanttt", # PR #40773 salvage (close hardline rm bypass via quoted paths and ${HOME} brace form) "290871358+Vesna-9@users.noreply.github.com": "Vesna-9", # PR #41274 salvage (collapse shell line continuations before dangerous/hardline pattern matching so `rm -rf \/` can't bypass the yolo-proof hardline floor) "214165399+kernel-t1@users.noreply.github.com": "kernel-t1", # PR #41349 salvage (.env sanitizer: only split when line starts with a known KEY= and preceding values are plain tokens; keep URL/query/whitespace secrets verbatim) @@ -1204,6 +1203,7 @@ "xiayh17@gmail.com": "xiayh0107", "zhujianxyz@gmail.com": "opriz", "tuancanhnguyen706@gmail.com": "xxxigm", + "timchris.roth@pm.me": "x9x9x9x9x9x91", "larcombe.n@gmail.com": "NickLarcombe", "54813621+xxxigm@users.noreply.github.com": "xxxigm", "asurla@nvidia.com": "anniesurla", diff --git a/tests/hermes_cli/test_anthropic_oauth_routes_to_messages_api.py b/tests/hermes_cli/test_anthropic_oauth_routes_to_messages_api.py new file mode 100644 index 000000000000..e813a375cc5c --- /dev/null +++ b/tests/hermes_cli/test_anthropic_oauth_routes_to_messages_api.py @@ -0,0 +1,205 @@ +"""Regression coverage for issue #32243. + +OAuth Pro/Max credentials must always reach Anthropic via the native +``/v1/messages`` endpoint, never the OpenAI-compat ``/chat/completions`` +shim — the latter bills against a separate "extra usage" pool that +Pro/Max subscriptions don't fund, so any request that lands on it 400s +with "You're out of extra usage" the moment the gateway starts. + +The root cause was an inconsistency between two URL→api_mode helpers: + +* ``hermes_cli.providers.determine_api_mode`` correctly mapped + ``api.anthropic.com`` to ``anthropic_messages``. +* ``hermes_cli.runtime_provider._detect_api_mode_for_url`` did NOT, so + every code path that fell back to URL-only detection (named custom + providers, direct-alias resolution, the api-key fallback inside + ``resolve_runtime_provider``) returned ``None`` for that host and + defaulted to ``chat_completions``. + +Exhaustive host-shape coverage for the helper itself lives in +``test_detect_api_mode_for_url.py::TestDirectAnthropicHost``. The +tests below pin the **integration contract**: every runtime branch +that resolves an Anthropic endpoint must return +``api_mode == "anthropic_messages"``, so a future refactor of any +single branch cannot silently revert #32243. +""" + +from __future__ import annotations + +from hermes_cli import runtime_provider as rp + + +class TestExplicitRuntimeForAnthropic: + """``_resolve_explicit_runtime`` with provider='anthropic' must + always return ``api_mode='anthropic_messages'`` regardless of + base_url shape or stale persisted ``model.api_mode`` values. + + Exercised whenever the user (or a Hermes subcommand) passes an + explicit ``--api-key`` / ``--base-url`` override to the runtime + resolver. + """ + + def test_explicit_args_route_to_messages_api(self): + result = rp._resolve_explicit_runtime( + provider="anthropic", + requested_provider="anthropic", + model_cfg={}, + explicit_api_key="sk-ant-oat01-foo", + explicit_base_url="https://api.anthropic.com", + ) + assert result is not None + assert result["api_mode"] == "anthropic_messages" + assert result["provider"] == "anthropic" + assert result["base_url"] == "https://api.anthropic.com" + + def test_stale_chat_completions_api_mode_in_config_is_ignored(self): + # A user who previously had ``provider: openai`` and switched to + # anthropic might still have ``model.api_mode: chat_completions`` + # in their config.yaml. The anthropic branch must hard-pin + # the mode — Anthropic's chat_completions shim is the bug + # locus of #32243 and must never be reachable from this path. + result = rp._resolve_explicit_runtime( + provider="anthropic", + requested_provider="anthropic", + model_cfg={"provider": "anthropic", "api_mode": "chat_completions"}, + explicit_api_key="sk-ant-oat01-foo", + explicit_base_url="https://api.anthropic.com", + ) + assert result is not None + assert result["api_mode"] == "anthropic_messages" + + def test_no_explicit_args_returns_none(self): + # Guard the gating contract — _resolve_explicit_runtime only + # fires when an explicit override is present; without one it + # must return None so the caller falls through to the pool / + # top-level anthropic branch. + assert ( + rp._resolve_explicit_runtime( + provider="anthropic", + requested_provider="anthropic", + model_cfg={"provider": "anthropic"}, + ) + is None + ) + + +class TestPoolEntryForAnthropic: + """``_resolve_runtime_from_pool_entry`` is what runs when a user + has added an OAuth credential via ``hermes auth add anthropic + --type oauth`` (the exact flow from #32243). Pin the contract + alongside the URL-detector test so all three runtime branches + stay aligned and a future refactor of one cannot diverge from + the others. + """ + + def test_oauth_pool_entry_routes_to_messages_api(self): + class _Entry: + access_token = "sk-ant-oat01-pool" + runtime_api_key = "sk-ant-oat01-pool" + source = "manual:hermes_pkce" + base_url = "https://api.anthropic.com" + + resolved = rp._resolve_runtime_from_pool_entry( + provider="anthropic", + entry=_Entry(), + requested_provider="anthropic", + model_cfg={"provider": "anthropic"}, + ) + + assert resolved["provider"] == "anthropic" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://api.anthropic.com" + + def test_stale_chat_completions_api_mode_in_config_is_ignored(self): + # Same regression as the explicit-runtime test above, but on + # the pool path: a stale persisted chat_completions api_mode + # must NOT override the provider-pin. + class _Entry: + access_token = "sk-ant-oat01-pool" + runtime_api_key = "sk-ant-oat01-pool" + source = "manual:hermes_pkce" + base_url = "https://api.anthropic.com" + + resolved = rp._resolve_runtime_from_pool_entry( + provider="anthropic", + entry=_Entry(), + requested_provider="anthropic", + model_cfg={ + "provider": "anthropic", + "api_mode": "chat_completions", + }, + ) + + assert resolved["api_mode"] == "anthropic_messages" + + +class TestCustomProviderUrlFallback: + """The detector fix's actual reachable path: a user-defined + ``providers:`` / ``custom_providers:`` entry whose ``api`` URL + points at ``api.anthropic.com``, with no explicit ``api_mode`` / + ``transport`` field. + + Pre-fix: this falls through ``_try_resolve_from_custom_pool`` → + ``_detect_api_mode_for_url("https://api.anthropic.com")`` → None → + default ``chat_completions`` → request lands on the OpenAI-compat + shim → "out of extra usage" 400. + + Post-fix: the detector returns ``anthropic_messages`` so the same + config routes to ``/v1/messages`` where Pro/Max OAuth is billed. + """ + + def test_url_fallback_picks_messages_api(self, monkeypatch): + class _Entry: + access_token = "sk-ant-oat01-custom-pool" + runtime_api_key = "sk-ant-oat01-custom-pool" + source = "custom-pool" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + monkeypatch.setattr(rp, "get_custom_provider_pool_key", lambda *a, **k: "custom:my-claude") + monkeypatch.setattr(rp, "load_pool", lambda key: _Pool()) + + resolved = rp._try_resolve_from_custom_pool( + "https://api.anthropic.com", + "custom", + ) + + assert resolved is not None + assert resolved["api_mode"] == "anthropic_messages" + + def test_explicit_api_mode_override_still_wins(self, monkeypatch): + # The detector is only consulted as a fallback — when the + # custom-pool caller passes an explicit api_mode (e.g. from a + # ``transport: chat_completions`` config entry), that takes + # priority. Pinned so the fix doesn't accidentally hijack a + # user who DELIBERATELY pointed a chat_completions transport + # at api.anthropic.com (uncommon but valid for OpenAI-compat + # experiments). + class _Entry: + access_token = "k" + runtime_api_key = "k" + source = "x" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + monkeypatch.setattr(rp, "get_custom_provider_pool_key", lambda *a, **k: "custom:my-claude") + monkeypatch.setattr(rp, "load_pool", lambda key: _Pool()) + + resolved = rp._try_resolve_from_custom_pool( + "https://api.anthropic.com", + "custom", + api_mode_override="chat_completions", + ) + + assert resolved is not None + assert resolved["api_mode"] == "chat_completions" diff --git a/tests/hermes_cli/test_detect_api_mode_for_url.py b/tests/hermes_cli/test_detect_api_mode_for_url.py index e9ee41dea71d..e776d0d4e46e 100644 --- a/tests/hermes_cli/test_detect_api_mode_for_url.py +++ b/tests/hermes_cli/test_detect_api_mode_for_url.py @@ -1,10 +1,15 @@ """Tests for hermes_cli.runtime_provider._detect_api_mode_for_url. -The helper maps base URLs to api_modes for three cases: - * api.openai.com → codex_responses - * api.x.ai → codex_responses - * */anthropic → anthropic_messages (third-party gateways like MiniMax, - Zhipu GLM, LiteLLM proxies) +The helper maps base URLs to api_modes for four cases: + * api.openai.com → codex_responses + * api.x.ai → codex_responses + * api.anthropic.com → anthropic_messages (Pro/Max OAuth is only billed + against /v1/messages; the + chat_completions shim counts + against a separate empty + "extra usage" pool, see #32243) + * */anthropic → anthropic_messages (third-party gateways like MiniMax, + Zhipu GLM, LiteLLM proxies) Consolidating the /anthropic detection in this helper (instead of three inline ``endswith`` checks spread across _resolve_runtime_from_pool_entry, @@ -38,6 +43,49 @@ def test_xai_host_suffix_does_not_match(self): assert _detect_api_mode_for_url("https://api.x.ai.example/v1") is None +class TestDirectAnthropicHost: + """Native api.anthropic.com → /v1/messages. Pinned for issue #32243. + + The Anthropic OpenAI-compat ``/chat/completions`` shim on the same + host bills against a separate "extra usage" pool that Pro/Max OAuth + subscriptions don't fund, so a fresh OAuth credential 400s with + "out of extra usage" the moment a request lands there. The detector + must keep ``api.anthropic.com`` on the native Messages API. + """ + + def test_bare_host(self): + assert _detect_api_mode_for_url("https://api.anthropic.com") == "anthropic_messages" + + def test_with_trailing_slash(self): + assert _detect_api_mode_for_url("https://api.anthropic.com/") == "anthropic_messages" + + def test_with_v1_suffix(self): + # The Anthropic SDK appends /v1/messages itself but the user's + # config may persist the /v1 form — must still resolve. + assert _detect_api_mode_for_url("https://api.anthropic.com/v1") == "anthropic_messages" + + def test_uppercase_host_tolerated(self): + assert _detect_api_mode_for_url("https://API.ANTHROPIC.COM/v1") == "anthropic_messages" + + def test_lookalike_subdomain_does_not_match(self): + # ``api.anthropic.com.attacker.test`` is an attacker-controlled + # host; the registrable label is ``attacker``, not Anthropic. + # Must NOT be routed to anthropic_messages — leaking an + # Anthropic OAuth token there is the worst case. + assert ( + _detect_api_mode_for_url("https://api.anthropic.com.attacker.test/v1") + is None + ) + + def test_anthropic_path_segment_does_not_match(self): + # A reverse proxy under an unrelated host whose path *contains* + # ``api.anthropic.com`` should not be classified as native. + assert ( + _detect_api_mode_for_url("https://proxy.example.test/api.anthropic.com/v1") + is None + ) + + class TestAnthropicMessagesDetection: """Third-party gateways that speak the Anthropic protocol under /anthropic.""" diff --git a/tests/run_agent/test_provider_fallback.py b/tests/run_agent/test_provider_fallback.py index b179cc341cc5..8a0e05c332c6 100644 --- a/tests/run_agent/test_provider_fallback.py +++ b/tests/run_agent/test_provider_fallback.py @@ -182,6 +182,35 @@ def test_resolves_key_env_for_fallback_provider(self): assert agent._try_activate_fallback() is True assert mock_rpc.call_args.kwargs["explicit_api_key"] == "env-secret" + def test_anthropic_host_custom_provider_uses_anthropic_messages(self): + """A custom provider on the native api.anthropic.com host (no + "/anthropic" path suffix, name != "anthropic") must resolve to the + anthropic_messages wire protocol — not default to chat_completions, + which POSTs /v1/chat/completions and 404s. Mirrors the primary-path + determine_api_mode() host check.""" + fbs = [ + { + "provider": "cron-anthropic", + "model": "claude-sonnet-4-6", + "base_url": "https://api.anthropic.com", + "key_env": "MY_FALLBACK_KEY", + } + ] + agent = _make_agent(fallback_model=fbs) + with ( + patch.dict("os.environ", {"MY_FALLBACK_KEY": "env-secret"}, clear=False), + patch( + "agent.auxiliary_client.resolve_provider_client", + return_value=( + _mock_client(base_url="https://api.anthropic.com"), + "claude-sonnet-4-6", + ), + ), + patch("hermes_cli.model_normalize.normalize_model_for_provider", side_effect=lambda m, p: m), + ): + assert agent._try_activate_fallback() is True + assert agent.api_mode == "anthropic_messages" + # ── Pool-rotation vs fallback gating (#11314) ────────────────────────────