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
12 changes: 11 additions & 1 deletion agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 \<newline>/` 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)
Expand Down Expand Up @@ -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",
Expand Down
205 changes: 205 additions & 0 deletions tests/hermes_cli/test_anthropic_oauth_routes_to_messages_api.py
Original file line number Diff line number Diff line change
@@ -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"
58 changes: 53 additions & 5 deletions tests/hermes_cli/test_detect_api_mode_for_url.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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."""

Expand Down
Loading
Loading