{"repo":"NousResearch/hermes-agent","public_id":"6475","display_label":"Anthropic OAuth subscription routing and billing errors","summary":"These issues and fixes center on Claude Pro/Max OAuth credentials being incorrectly routed to pay-per-token endpoints, triggering HTTP 400 'out of extra usage' errors due to missing billing headers, wrong tool name formats, empty API keys, and misclassified error responses. Multiple fixes address routing, wire format, header requirements, and error classification for OAuth subscription users.","subsystem":"agent","counts":{"members":53,"open_issues":4,"open_issues_distinct":4,"open_prs":2,"closed_issues":22,"merged_prs":6,"attempt_prs":21},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":47723},"resolution":{"state":"partially_resolved","by":"pr-77923,pr-100173"},"freshness":{"state":"verified","last_verified_at":"2026-09-01T10:57:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.03965166908563133},"fix_edges":[{"issue_number":6475,"pr_number":56128,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":28849,"pr_number":47723,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":32243,"pr_number":56214,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":46675,"pr_number":47723,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":58546,"pr_number":77923,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":65365,"pr_number":86677,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":65365,"pr_number":100173,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":82154,"pr_number":86677,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":82154,"pr_number":100173,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":6475,"title":"Anthropic Claude subscription auth returns 'You're out of extra usage' in Hermes even after restart/re-login","url":null,"state":"closed"},{"kind":"issue","number":7863,"title":"suppressed_sources in auth.json is dead config — credential pool ignores it","url":null,"state":"closed"},{"kind":"issue","number":10575,"title":"[Bug] Anthropic OAuth/Claude Max proxy path can misclassify full Hermes system prompt as extra-usage exhausted","url":null,"state":"closed"},{"kind":"issue","number":12905,"title":"[Bug]: Anthropic provider OAuth integration has multiple issues with credential management and subscription routing","url":null,"state":"closed"},{"kind":"issue","number":15080,"title":"[Bug]: On a Claude Max 20x subscription with a valid OAuth access token from ~/.claude/.credentials.json, every Hermes request to native Anthropic (provider: anthropic, https://api.anthropic.com/v1/messages) is rejected with HTTP 400","url":null,"state":"closed"},{"kind":"issue","number":15291,"title":"[Bug]: Bug? OAuth CC billing all usage as extra usage","url":null,"state":"closed"},{"kind":"issue","number":20732,"title":"Error code: 400 | \"You're out of extra usage. Add more at claude.ai/settings/usage and keep going.\"","url":null,"state":"closed"},{"kind":"issue","number":23370,"title":"Anthropic provider sends `Bearer None` to /chat/completions instead of /v1/messages with OAuth credential","url":null,"state":"closed"},{"kind":"issue","number":28849,"title":"OAuth Anthropic + Pro/Max plan: mcp_ tool-name prefix triggers \"out of extra usage\" 400 on every tool-bearing   request","url":null,"state":"closed"},{"kind":"issue","number":29125,"title":"[Bug]: Hermes does not work through Claude CLI","url":null,"state":"closed"},{"kind":"issue","number":29539,"title":"[Bug]: claude code pro/max changes to api mode","url":null,"state":"closed"},{"kind":"issue","number":31668,"title":"[Bug]: Hermes w/ Anthropic models ratelimit/extra usage","url":null,"state":"closed"},{"kind":"issue","number":32243,"title":"[Bug]:  OAuth Pro/Max credential returns \"out of extra usage\" 400 immediately after fresh login, despite empty/unused account quota","url":null,"state":"closed"},{"kind":"issue","number":33192,"title":"[Setup]: Hermes Model - Anthropic Claude Pro - OAuth wrong routing","url":null,"state":"closed"},{"kind":"issue","number":37363,"title":"[Setup]: Problem at 0Auth","url":null,"state":"closed"},{"kind":"issue","number":40014,"title":"Claude Code OAuth (Max/Pro plan) still hits pay-per-token API endpoint — drains 'extra usage' credits instead of using subscription quota","url":null,"state":"closed"},{"kind":"issue","number":45155,"title":"Doctor reports Anthropic OK while real invocation fails; oneshot hides provider error","url":null,"state":"open"},{"kind":"issue","number":46675,"title":"Max OAuth requests rejected as third-party (HTTP 400 \"extra usage\") due to single-underscore `mcp_` tool-name prefix","url":null,"state":"closed"},{"kind":"issue","number":47260,"title":"Anthropic Claude Code OAuth still appears to consume extra usage credits after June 16 policy rollback email","url":null,"state":"open"},{"kind":"issue","number":48176,"title":"[Bug]: OAuth Pro/Max/Team requests rejected with HTTP 400 \"third-party / extra usage\" (missing x-anthropic-billing-header system block)","url":null,"state":"closed"},{"kind":"issue","number":53212,"title":"Claude Code / subscription OAuth requests are billed as overage (HTTP 429 \"monthly spend limit\") — root cause: the agent system prompt trips Anthropic's subscription billing classifier","url":null,"state":"closed"},{"kind":"issue","number":54206,"title":"Anthropic api_mode + credential lost on oneshot (-z) AND interactive --resume: request sent as chat_completions + Bearer None","url":null,"state":"closed"},{"kind":"issue","number":58546,"title":"resolve_anthropic_token() prefers auto-discovered Claude Code OAuth over an explicitly configured ANTHROPIC_API_KEY","url":null,"state":"closed"},{"kind":"issue","number":65078,"title":"[Bug]: claude /login become API Error: HTTP 400","url":null,"state":"open"},{"kind":"issue","number":65365,"title":"OAuth (Claude Pro/Max): exposing `memory` or `session_search` tool schema deterministically triggers HTTP 400 \"You're out of extra usage\"","url":null,"state":"closed"},{"kind":"issue","number":65564,"title":"Anthropic OAuth (Claude Pro/Max subscription) hits \"You're out of extra usage\" on every real request — routes through pay-per-token endpoint instead of subscription quota","url":null,"state":"open"},{"kind":"pr","number":6498,"title":"[codex] Clarify Anthropic extra-usage exhaustion errors","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56128","number":56128},{"kind":"pr","value":"#56128","number":56128},{"kind":"pr","value":"#49379","number":49379}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":6854,"title":"fix: Anthropic OAuth sends empty X-Api-Key, misclassifies billing error","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":10576,"title":"fix(anthropic): sanitize oauth system prompt for Claude Max proxy","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6475","number":6475},{"kind":"pr","value":"#6475","number":6475},{"kind":"pr","value":"#6475","number":6475}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":12971,"title":"fix: re-auth on stale OAuth token; read Claude Code credentials from macOS Keychain","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e1106772d","number":0},{"kind":"commit","value":"e1106772d9c9ff45081fbca737df1eae8fa98f61","number":0},{"kind":"commit","value":"e1106772d","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19260,"title":"fix(auth): stop treating CLAUDE_CODE_OAUTH_TOKEN as Anthropic API key","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":19361,"title":"fix(anthropic): route OAuth auth flow at correct claude.com/cai endpoints (#15291)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":21019,"title":"fix(anthropic): classify OAuth tool-use overage failures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ea9e8d6e8c80fbdd0b0b4864b833d2bfd3b429bb","number":0},{"kind":"commit","value":"e00800fc89e761382326c3a5eef8f4cccfbbc205","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":24250,"title":"fix(anthropic): work around OAuth third-party billing-lane classifier (#15080)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#15080","number":15080}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":26960,"title":"feat(agent): add ClaudeLocalClient subprocess shim for Claude Max OAuth accounts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6427","number":6427},{"kind":"pr","value":"#12229","number":12229},{"kind":"pr","value":"#6427","number":6427},{"kind":"pr","value":"#12229","number":12229},{"kind":"pr","value":"#15080","number":15080}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":28872,"title":"fix(anthropic): remove mcp_ tool prefix on OAuth path that triggers overage gate","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47723","number":47723},{"kind":"pr","value":"#47723","number":47723},{"kind":"pr","value":"#47723","number":47723}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32318,"title":"fix(provider): route OAuth Pro/Max credentials to /v1/messages (#32243)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a344c9205","number":0},{"kind":"commit","value":"a2251b40e","number":0},{"kind":"commit","value":"9efe01c3a","number":0},{"kind":"pr","value":"#56214","number":56214}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40020,"title":"fix: classify Anthropic subscription_exhausted (HTTP 400 \"out of extra usage\") with actionable error message","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":46687,"title":"fix(anthropic): use double-underscore mcp__ prefix for OAuth tool names","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47723","number":47723},{"kind":"pr","value":"#47723","number":47723}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":47723,"title":"fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/transports/anthropic.py","tests/agent/test_anthropic_mcp_prefix_strip.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:\n \n \n _CLAUDE_CODE_SYSTEM_PREFIX = \"You are Claude Code, Anthropic's official CLI for Claude.\"\n-_MCP_TOOL_PREFIX = \"mcp_\"\n+_MCP_TOOL_PREFIX = \"mcp__\"\n \n \n def _get_claude_code_version() -> str:","@@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(\n                 text = text.replace(\"Nous Research\", \"Anthropic\")\n                 block[\"text\"] = text\n \n-        # 3. Prefix tool names with mcp_ (Claude Code convention)\n-        #    Skip names that already begin with the marker — native MCP server\n-        #    tools (from mcp_servers: in config.yaml) are registered under their\n-        #    full mcp_<server>_<tool> name and would double-prefix otherwise,\n-        #    breaking round-trip registry lookup in normalize_response. GH-25255.\n+        # 3. Normalize tool names so NOTHING goes on the OAuth wire with a\n+        #    single-underscore ``mcp_`` prefix.  Anthropic's subscription/OAuth\n+        #    billing classifier treats a single-underscore ``mcp_`` tool name as\n+        #    a third-party-app fingerprint and rejects the request with HTTP 400\n+        #    \"Third-party apps now draw from extra usage, not plan limits\"\n+        #    (verified empirically: a single ``mcp_foo`` tool flips a request\n+        #    from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).\n+        #\n+        #    Two cases, both must land on the double-underscore ``mcp__`` form:\n+        #      a) bare Hermes-native tools (``read_file``)  -> ``mcp__read_file``\n+        #      b) native MCP server tools registered under their full\n+        #         single-underscore ``mcp_<server>_<tool>`` name\n+        #         (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``\n+        #    Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap\n+        #    left open: those tools were *skipped* and stayed single-underscore,\n+        #    so any session with an MCP server configured still tripped the\n+        #    classifier. normalize_response reverses both forms via registry\n+        #    lookup so the dispatcher still sees the original name. GH-25255.\n+        def _to_oauth_wire_name(name: str) -> str:\n+            if name.startswith(\"mcp__\"):\n+                return name  # already correct, don't double-prefix\n+            if name.startswith(\"mcp_\"):\n+                # single-underscore native MCP tool -> promote to double\n+                return \"mcp__\" + name[len(\"mcp_\"):]\n+            return _MCP_TOOL_PREFIX + name  # bare name -> mcp__<name>\n+\n         if anthropic_tools:\n             for tool in anthropic_tools:\n-                if \"name\" in tool and not tool[\"name\"].startswith(_MCP_TOOL_PREFIX):\n-                    tool[\"name\"] = _MCP_TOOL_PREFIX + tool[\"name\"]\n+                if \"name\" in tool:\n+                    tool[\"name\"] = _to_oauth_wire_name(tool[\"name\"])\n \n-        # 4. Prefix tool names in message history (tool_use and tool_result blocks)\n+        # 4. Apply the same normalization to tool names in message history\n+        #    (tool_use blocks) so replayed turns match the wire names above.\n         for msg in anthropic_messages:\n             content = msg.get(\"content\")\n             if isinstance(content, list):\n                 for block in content:\n                     if isinstance(block, dict):\n                         if block.get(\"type\") == \"tool_use\" and \"name\" in block:\n-                            if not block[\"name\"].startswith(_MCP_TOOL_PREFIX):\n-                                block[\"name\"] = _MCP_TOOL_PREFIX + block[\"name\"]\n+                            block[\"name\"] = _to_oauth_wire_name(block[\"name\"])\n                         elif block.get(\"type\") == \"tool_result\" and \"tool_use_id\" in block:\n                             pass  # tool_result uses ID, not name\n "]},{"file":"agent/transports/anthropic.py","hunks":["@@ -88,7 +88,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:\n         from agent.transports.types import ToolCall\n \n         strip_tool_prefix = kwargs.get(\"strip_tool_prefix\", False)\n-        _MCP_PREFIX = \"mcp_\"\n+        _MCP_PREFIX = \"mcp__\"\n \n         text_parts = []\n         reasoning_parts = []","@@ -132,17 +132,25 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:\n             elif block.type == \"tool_use\":\n                 name = block.name\n                 if strip_tool_prefix and name.startswith(_MCP_PREFIX):\n-                    stripped = name[len(_MCP_PREFIX):]\n-                    # Only strip the mcp_ prefix for OAuth-injected tools\n-                    # (where Hermes adds the prefix when sending to Anthropic\n-                    # and must remove it on the way back).  Native MCP server\n-                    # tools (from mcp_servers: in config.yaml) are registered\n-                    # in the tool registry under their FULL mcp_<server>_<tool>\n-                    # name and must NOT be stripped.  GH-25255.\n+                    # On the OAuth wire every tool carries a double-underscore\n+                    # ``mcp__`` prefix (added in build_anthropic_kwargs to avoid\n+                    # Anthropic's single-underscore third-party classifier).\n+                    # Reverse it back to the name the registry/dispatcher knows.\n+                    # Two original forms map onto the same ``mcp__`` wire name:\n+                    #   ``mcp__read_file``       <- bare native tool ``read_file``\n+                    #   ``mcp__linear_get_issue`` <- MCP server tool\n+                    #                                ``mcp_linear_get_issue``\n+                    # Resolve by registry lookup, preferring whichever original\n+                    # is actually registered; never rewrite a name the LLM used\n+                    # that already resolves natively. GH-25255.\n                     from tools.registry import registry as _tool_registry\n-                    if (_tool_registry.get_entry(stripped)\n-                            and not _tool_registry.get_entry(name)):\n-                        name = stripped\n+                    if not _tool_registry.get_entry(name):\n+                        bare = name[len(_MCP_PREFIX):]            # read_file\n+                        single = \"mcp_\" + bare                    # mcp_read_file / mcp_linear_get_issue\n+                        if _tool_registry.get_entry(single):\n+                            name = single\n+                        elif _tool_registry.get_entry(bare):\n+                            name = bare\n                 tool_calls.append(\n                     ToolCall(\n                         id=block.id,"]},{"file":"tests/agent/test_anthropic_mcp_prefix_strip.py","hunks":["@@ -1,9 +1,16 @@\n-\"\"\"Tests for GH-25255: Anthropic OAuth mcp_ prefix stripping.\n+\"\"\"Tests for GH-25255: Anthropic OAuth ``mcp__`` tool-name round-trip.\n \n-When strip_tool_prefix=True (Anthropic OAuth path), the transport must only\n-strip the ``mcp_`` prefix from OAuth-injected tools, NOT from Hermes-native\n-MCP server tools that are registered under their full ``mcp_<server>_<tool>``\n-name in the tool registry.\n+Anthropic's subscription/OAuth billing classifier treats a **single-underscore**\n+``mcp_`` tool name as a third-party-app fingerprint and rejects the request with\n+HTTP 400 \"Third-party apps now draw from extra usage, not plan limits\".  So on\n+the OAuth wire NOTHING may carry a single-underscore ``mcp_`` prefix:\n+\n+  * bare native tools            ``read_file``            -> ``mcp__read_file``\n+  * native MCP server tools      ``mcp_linear_get_issue`` -> ``mcp__linear_get_issue``\n+\n+``normalize_response`` reverses the ``mcp__`` wire name back to whatever the tool\n+registry knows (the single-underscore ``mcp_<server>_<tool>`` form for MCP server\n+tools, or the bare name for native tools) so the dispatcher is unaffected.\n \"\"\"\n \n from __future__ import annotations","@@ -12,7 +19,6 @@\n from unittest.mock import patch\n \n \n-\n # ---------------------------------------------------------------------------\n # Helpers\n # ---------------------------------------------------------------------------","@@ -38,7 +44,7 @@ def _make_response(*blocks, stop_reason=\"end_turn\"):\n \n \n class _FakeRegistry:\n-    \"\"\"Minimal fake tool registry for testing prefix stripping logic.\"\"\"\n+    \"\"\"Minimal fake tool registry for testing prefix round-trip logic.\"\"\"\n \n     def __init__(self, registered_names: set[str]):\n         self._names = registered_names","@@ -50,25 +56,20 @@ def get_entry(self, name: str):\n \n \n # ---------------------------------------------------------------------------\n-# Tests\n+# Response side: mcp__ wire name -> registry name\n # ---------------------------------------------------------------------------\n \n class TestAnthropicMcpPrefixStrip:\n-    \"\"\"Verify that strip_tool_prefix only strips OAuth-injected prefixes.\"\"\"\n+    \"\"\"Verify strip_tool_prefix reverses the ``mcp__`` wire prefix correctly.\"\"\"\n \n     def _get_transport(self):\n         from agent.transports.anthropic import AnthropicTransport\n         return AnthropicTransport()\n \n-    def test_strips_prefix_for_oauth_injected_tool(self):\n-        \"\"\"OAuth tools: mcp_read_file -> read_file (stripped).\n-\n-        The tool was registered as 'read_file' in the registry.\n-        Anthropic sees 'mcp_read_file' because Hermes adds the prefix.\n-        On response, we must strip it back to 'read_file'.\n-        \"\"\"\n+    def test_strips_prefix_for_oauth_injected_native_tool(self):\n+        \"\"\"``mcp__read_file`` -> ``read_file`` (bare native tool).\"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_read_file\")\n+        block = _make_tool_use_block(\"mcp__read_file\")\n         response = _make_response(block)\n \n         registry = _FakeRegistry({\"read_file\", \"terminal\", \"web_search\"})","@@ -78,31 +79,29 @@ def test_strips_prefix_for_oauth_injected_tool(self):\n         assert len(result.tool_calls) == 1\n         assert result.tool_calls[0].name == \"read_file\"\n \n-    def test_preserves_native_mcp_server_tool_name(self):\n-        \"\"\"Native MCP tools: mcp_composio_SEARCH -> mcp_composio_SEARCH (kept).\n+    def test_restores_single_underscore_mcp_server_tool(self):\n+        \"\"\"``mcp__linear_get_issue`` -> ``mcp_linear_get_issue`` (MCP server tool).\n \n-        The tool is registered with the full mcp_ prefix in the registry.\n-        Stripping would break registry lookup.\n+        MCP server tools are registered under their full single-underscore\n+        ``mcp_<server>_<tool>`` name, but they MUST go on the OAuth wire as\n+        double-underscore to dodge the classifier.  The response side restores\n+        the single-underscore registry name so dispatch still resolves.\n         \"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_composio_COMPOSIO_SEARCH_TOOLS\")\n+        block = _make_tool_use_block(\"mcp__linear_get_issue\")\n         response = _make_response(block)\n \n-        registry = _FakeRegistry({\n-            \"mcp_composio_COMPOSIO_SEARCH_TOOLS\",\n-            \"mcp_composio_COMPOSIO_GET_TOOL_SCHEMAS\",\n-            \"read_file\",\n-        })\n+        registry = _FakeRegistry({\"mcp_linear_get_issue\", \"read_file\"})\n         with patch(\"tools.registry.registry\", registry):\n             result = transport.normalize_response(response, strip_tool_prefix=True)\n \n         assert len(result.tool_calls) == 1\n-        assert result.tool_calls[0].name == \"mcp_composio_COMPOSIO_SEARCH_TOOLS\"\n+        assert result.tool_calls[0].name == \"mcp_linear_get_issue\"\n \n     def test_no_strip_when_flag_false(self):\n         \"\"\"When strip_tool_prefix=False, names are never modified.\"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_read_file\")\n+        block = _make_tool_use_block(\"mcp__read_file\")\n         response = _make_response(block)\n \n         registry = _FakeRegistry({\"read_file\"})","@@ -110,10 +109,10 @@ def test_no_strip_when_flag_false(self):\n             result = transport.normalize_response(response, strip_tool_prefix=False)\n \n         assert len(result.tool_calls) == 1\n-        assert result.tool_calls[0].name == \"mcp_read_file\"\n+        assert result.tool_calls[0].name == \"mcp__read_file\"\n \n     def test_no_strip_when_not_mcp_prefixed(self):\n-        \"\"\"Non-mcp_ names are untouched regardless of strip flag.\"\"\"\n+        \"\"\"Non-``mcp__`` names are untouched regardless of strip flag.\"\"\"\n         transport = self._get_transport()\n         block = _make_tool_use_block(\"web_search\")\n         response = _make_response(block)","@@ -125,68 +124,61 @@ def test_no_strip_when_not_mcp_prefixed(self):\n         assert len(result.tool_calls) == 1\n         assert result.tool_calls[0].name == \"web_search\"\n \n-    def test_preserves_name_when_neither_in_registry(self):\n-        \"\"\"When neither stripped nor full name is in registry, keep full name.\n+    def test_preserves_name_when_no_original_in_registry(self):\n+        \"\"\"Neither the single-underscore nor bare original is registered.\n \n-        Safety fallback: if we can't determine the type, prefer the full name\n-        since it's what the LLM was told about.\n+        Safety fallback: keep the full ``mcp__`` name the LLM was told about.\n         \"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_unknown_tool\")\n+        block = _make_tool_use_block(\"mcp__unknown_tool\")\n         response = _make_response(block)\n \n-        registry = _FakeRegistry({\"read_file\"})  # neither name registered\n+        registry = _FakeRegistry({\"read_file\"})  # no matching original\n         with patch(\"tools.registry.registry\", registry):\n             result = transport.normalize_response(response, strip_tool_prefix=True)\n \n         assert len(result.tool_calls) == 1\n-        assert result.tool_calls[0].name == \"mcp_unknown_tool\"\n+        assert result.tool_calls[0].name == \"mcp__unknown_tool\"\n \n-    def test_mixed_tools_same_response(self):\n-        \"\"\"Both OAuth and native MCP tools in the same response.\"\"\"\n+    def test_mixed_native_and_mcp_server_tools_same_response(self):\n+        \"\"\"A bare native tool and an MCP server tool, both wired as ``mcp__``.\"\"\"\n         transport = self._get_transport()\n-        block1 = _make_tool_use_block(\"mcp_read_file\", block_id=\"tc_1\")\n-        block2 = _make_tool_use_block(\"mcp_composio_SEARCH\", block_id=\"tc_2\")\n-        block3 = _make_tool_use_block(\"mcp_composio_SEARCH\", block_id=\"tc_3\")  # also registered natively\n-        response = _make_response(block1, block2, block3)\n-\n-        registry = _FakeRegistry({\n-            \"read_file\",  # OAuth-injected\n-            \"mcp_composio_SEARCH\",  # native MCP\n-        })\n+        block1 = _make_tool_use_block(\"mcp__read_file\", block_id=\"tc_1\")\n+        block2 = _make_tool_use_block(\"mcp__linear_get_issue\", block_id=\"tc_2\")\n+        response = _make_response(block1, block2)\n+\n+        registry = _FakeRegistry({\"read_file\", \"mcp_linear_get_issue\"})\n         with patch(\"tools.registry.registry\", registry):\n             result = transport.normalize_response(response, strip_tool_prefix=True)\n \n-        assert len(result.tool_calls) == 3\n-        # OAuth tool: stripped\n+        assert len(result.tool_calls) == 2\n         assert result.tool_calls[0].name == \"read_file\"\n-        # Native MCP: preserved (both stripped and full are registered, full wins)\n-        assert result.tool_calls[1].name == \"mcp_composio_SEARCH\"\n-        assert result.tool_calls[2].name == \"mcp_composio_SEARCH\"\n+        assert result.tool_calls[1].name == \"mcp_linear_get_issue\"\n \n-    def test_both_stripped_and_full_registered_prefers_full(self):\n-        \"\"\"Edge case: both 'foo' and 'mcp_foo' exist in registry.\n+    def test_prefers_full_wire_name_when_it_resolves_directly(self):\n+        \"\"\"If the ``mcp__`` wire name itself is registered, keep it as-is.\n \n-        Keep 'mcp_foo' (the original name) since it's what the LLM requested.\n+        Defensive: never rewrite a name that already resolves natively.\n         \"\"\"\n         transport = self._get_transport()\n-        block = _make_tool_use_block(\"mcp_foo\")\n+        block = _make_tool_use_block(\"mcp__foo\")\n         response = _make_response(block)\n \n-        registry = _FakeRegistry({\"foo\", \"mcp_foo\"})\n+        registry = _FakeRegistry({\"foo\", \"mcp__foo\"})\n         with patch(\"tools.registry.registry\", registry):\n             result = transport.normalize_response(response, strip_tool_prefix=True)\n \n         assert len(result.tool_calls) == 1\n-        # Both exist — the condition `get_entry(stripped) and not get_entry(name)`\n-        # is False because get_entry(name) IS truthy, so we keep the full name.\n-        assert result.tool_calls[0].name == \"mcp_foo\"\n+        assert result.tool_calls[0].name == \"mcp__foo\"\n \n \n+# ---------------------------------------------------------------------------\n+# Request side: registry name -> mcp__ wire name (no single-underscore leaks)\n+# ---------------------------------------------------------------------------\n+\n class TestAnthropicOAuthOutgoingPrefix:\n-    \"\"\"Verify the outgoing-side companion fix: build_anthropic_kwargs must not\n-    double-prefix tool names that already start with ``mcp_`` (native MCP server\n-    tools registered as ``mcp_<server>_<tool>``). GH-25255.\"\"\"\n+    \"\"\"build_anthropic_kwargs must emit ZERO single-underscore ``mcp_`` names on\n+    the OAuth wire — bare names and MCP server names both land on ``mcp__``.\"\"\"\n \n     def _build(self, tools, is_oauth=True):\n         from agent.anthropic_adapter import build_anthropic_kwargs","@@ -199,50 +191,65 @@ def _build(self, tools, is_oauth=True):\n             is_oauth=is_oauth,\n         )\n \n-    def test_oauth_adds_prefix_to_bare_tool_name(self):\n-        \"\"\"OAuth + bare name → prefix added (existing Claude Code convention).\"\"\"\n+    def test_oauth_adds_double_prefix_to_bare_tool_name(self):\n+        \"\"\"OAuth + bare name -> ``mcp__`` prefix added.\"\"\"\n         kwargs = self._build([{\n             \"type\": \"function\",\n             \"function\": {\"name\": \"read_file\", \"description\": \"x\", \"parameters\": {}},\n         }])\n-        names = [t[\"name\"] for t in kwargs[\"tools\"]]\n-        assert names == [\"mcp_read_file\"]\n+        assert [t[\"name\"] for t in kwargs[\"tools\"]] == [\"mcp__read_file\"]\n \n-    def test_oauth_does_not_double_prefix_native_mcp_tool(self):\n-        \"\"\"OAuth + already-prefixed native MCP name → left alone.\"\"\"\n+    def test_oauth_promotes_single_underscore_mcp_server_tool(self):\n+        \"\"\"OAuth + ``mcp_<server>_<tool>`` -> promoted to double underscore.\n+\n+        This is the gap left by the bare constant swap: MCP server tools used\n+        to be *skipped* and went on the wire single-underscore, still tripping\n+        the classifier.  They must become ``mcp__`` and NOT be double-prefixed.\n+        \"\"\"\n         kwargs = self._build([{\n             \"type\": \"function\",\n             \"function\": {\n-                \"name\": \"mcp_composio_COMPOSIO_SEARCH_TOOLS\",\n+                \"name\": \"mcp_linear_get_issue\",\n                 \"description\": \"x\",\n                 \"parameters\": {},\n             },\n         }])\n         names = [t[\"name\"] for t in kwargs[\"tools\"]]\n-        # Must NOT become \"mcp_mcp_composio_...\" — that breaks the round-trip\n-        # because normalize_response only strips ONE mcp_ prefix.\n-        assert names == [\"mcp_composio_COMPOSIO_SEARCH_TOOLS\"]\n+        assert names == [\"mcp__linear_get_issue\"]\n+        # never double-prefixed\n+        assert not any(n.startswith(\"mcp__mcp_\") for n in names)\n+\n+    def test_oauth_already_double_prefixed_left_alone(self):\n+        \"\"\"OAuth + already-``mcp__`` name -> unchanged (no triple underscore).\"\"\"\n+        kwargs = self._build([{\n+            \"type\": \"function\",\n+            \"function\": {\"name\": \"mcp__already\", \"description\": \"x\", \"parameters\": {}},\n+        }])\n+        assert [t[\"name\"] for t in kwargs[\"tools\"]] == [\"mcp__already\"]\n \n-    def test_oauth_mixed_native_and_bare_tools(self):\n-        \"\"\"Mixed: native MCP preserved, bare names prefixed.\"\"\"\n+    def test_oauth_no_single_underscore_mcp_on_wire(self):\n+        \"\"\"Mixed set: every wire name is bare-free of single-underscore mcp_.\"\"\"\n         kwargs = self._build([\n             {\"type\": \"function\", \"function\": {\"name\": \"read_file\",\n-                                               \"description\": \"x\", \"parameters\": {}}},\n-            {\"type\": \"function\", \"function\": {\"name\": \"mcp_composio_SEARCH\",\n-                                               \"description\": \"y\", \"parameters\": {}}},\n+                                              \"description\": \"x\", \"parameters\": {}}},\n+            {\"type\": \"function\", \"function\": {\"name\": \"mcp_linear_get_issue\",\n+                                              \"description\": \"y\", \"parameters\": {}}},\n             {\"type\": \"function\", \"function\": {\"name\": \"terminal\",\n-                                               \"description\": \"z\", \"parameters\": {}}},\n+                                              \"description\": \"z\", \"parameters\": {}}},\n         ])\n         names = sorted(t[\"name\"] for t in kwargs[\"tools\"])\n-        assert names == [\"mcp_composio_SEARCH\", \"mcp_read_file\", \"mcp_terminal\"]\n+        assert names == [\"mcp__linear_get_issue\", \"mcp__read_file\", \"mcp__terminal\"]\n+        # The core invariant: NOTHING single-underscore reaches the wire.\n+        for n in names:\n+            assert not (n.startswith(\"mcp_\") and not n.startswith(\"mcp__\"))\n \n     def test_non_oauth_path_untouched(self):\n         \"\"\"Non-OAuth requests never get the prefix — schemas pass through as-is.\"\"\"\n         kwargs = self._build([\n             {\"type\": \"function\", \"function\": {\"name\": \"read_file\",\n-                                               \"description\": \"x\", \"parameters\": {}}},\n-            {\"type\": \"function\", \"function\": {\"name\": \"mcp_composio_SEARCH\",\n-                                               \"description\": \"y\", \"parameters\": {}}},\n+                                              \"description\": \"x\", \"parameters\": {}}},\n+            {\"type\": \"function\", \"function\": {\"name\": \"mcp_linear_get_issue\",\n+                                              \"description\": \"y\", \"parameters\": {}}},\n         ], is_oauth=False)\n         names = sorted(t[\"name\"] for t in kwargs[\"tools\"])\n-        assert names == [\"mcp_composio_SEARCH\", \"read_file\"]\n+        assert names == [\"mcp_linear_get_issue\", \"read_file\"]\n"]}]},{"kind":"pr","number":48177,"title":"fix(agent): send Claude Code billing-attribution system block on OAuth requests","url":null,"state":"closed","close_reason":{"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":48202,"title":"fix(anthropic): add billing header for OAuth plan-tier routing (#48176)","url":null,"state":"closed","close_reason":{"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":52473,"title":"fix(cli): surface provider errors in -z oneshot instead of swallowing them","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"093f567f0d73705c2bfc1178301817dbc32cf837","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":54221,"title":"fix(debug): show correct URL and API key in request dumps for all api_modes","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":56128,"title":"fix(classifier): treat Anthropic \"out of extra usage\" 400 as billing","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_loop.py","agent/error_classifier.py","scripts/release.py","tests/agent/test_anthropic_billing_guidance.py","tests/agent/test_error_classifier.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -205,6 +205,26 @@ def _billing_or_entitlement_message(\n \n     provider_label = (provider or \"\").strip() or \"the selected provider\"\n     model_label = (model or \"\").strip() or \"the selected model\"\n+\n+    # Anthropic Claude Pro/Max OAuth subscriptions surface exhaustion of the\n+    # metered \"extra usage\" bucket as a hard 400 (\"You're out of extra\n+    # usage\"). Point at the exact settings page and note the cycle-reset\n+    # option, since the generic \"add credits with that provider\" line doesn't\n+    # apply to a subscription — the user waits for the reset or switches to an\n+    # API key.\n+    if (provider or \"\").strip().lower() == \"anthropic\":\n+        lines = [\n+            (\n+                f\"{provider_label} reported that your Claude subscription usage is \"\n+                f\"exhausted for {model_label} (included quota + extra-usage credits).\"\n+            ),\n+            \"Options: wait for the billing cycle to reset, or add extra usage at \"\n+            \"https://claude.ai/settings/usage\",\n+            \"You can also switch to an Anthropic API key or another provider with \"\n+            \"/model <model> --provider <provider>.\",\n+        ]\n+        return \"\\n\".join(lines)\n+\n     lines = [\n         (\n             f\"{provider_label} reported that billing, credits, or account \""]},{"file":"agent/error_classifier.py","hunks":["@@ -110,6 +110,7 @@ def is_auth(self) -> bool:\n     \"exceeded your current quota\",\n     \"account is deactivated\",\n     \"plan does not include\",\n+    \"out of extra usage\",  # Anthropic OAuth Pro/Max overage bucket depleted (HTTP 400)\n     \"out of funds\",\n     \"run out of funds\",\n     \"balance_depleted\","]},{"file":"scripts/release.py","hunks":["@@ -49,6 +49,8 @@\n     \"[redacted:email]\": \"jnibarger01\",  # PR #35130 salvage (ReDoS-bound threat-pattern filler + FTS5 query cap + V4A Move-File approval/traversal targets)\n     \"[redacted:email]\": \"petrichor-op\",  # PR #41281 salvage (never persist ephemeral empty-response recovery scaffolding to the SQLite session store / JSON log; filter by flag not position)\n     \"[redacted:email]\": \"redactdeveloper\",  # PR #36897 salvage (route /sessions & /history through prompt_toolkit-safe print; filter doctor missing-key summary to CLI-enabled toolsets)\n+    \"[redacted:email]\": \"charleneleong-ai\",  # PR #11736 salvage (classify Anthropic \"out of extra usage\" 400 as billing)\n+    \"[redacted:email]\": \"janrenz\",  # PR #35862 salvage (prompt_caching.enabled escape hatch for strict providers)\n     \"[redacted:email]\": \"syahidfrd\",  # PR #17059 salvage (tag unverified senders in Slack thread context to mitigate indirect prompt injection)\n     \"[redacted:email]\": \"H2KFORGIVEN\",  # PR #22523 salvage (turn-pair preservation: never orphan the last user ask at head_end during compaction)\n     \"[redacted:email]\": \"sgabel\",  # PR #13139 salvage (redact secrets in user-facing approval prompts)"]},{"file":"tests/agent/test_anthropic_billing_guidance.py","hunks":["@@ -0,0 +1,46 @@\n+\"\"\"Tests for the Anthropic-subscription branch of\n+``agent.conversation_loop._billing_or_entitlement_message``.\n+\n+Regression context: Anthropic Claude Pro/Max OAuth subscriptions surface\n+exhaustion of the metered \"extra usage\" bucket as a hard HTTP 400\n+(\"You're out of extra usage. Add more at claude.ai/settings/usage...\"),\n+which classifies as ``FailoverReason.billing``. The generic billing\n+guidance (\"add credits with that provider\") is wrong for a subscription —\n+the user waits for the cycle reset or switches to an API key. This branch\n+gives Anthropic-specific, actionable guidance (folds in PR #40073's UX).\n+\"\"\"\n+from __future__ import annotations\n+\n+from agent.conversation_loop import _billing_or_entitlement_message\n+\n+\n+def test_anthropic_subscription_exhausted_guidance():\n+    \"\"\"Anthropic billing guidance points at the exact settings page and\n+    the cycle-reset option, not the generic 'add credits' line.\"\"\"\n+    msg = _billing_or_entitlement_message(\n+        capability=\"model access\",\n+        provider=\"anthropic\",\n+        base_url=\"https://api.anthropic.com\",\n+        model=\"claude-opus-4-7\",\n+    )\n+    assert \"claude.ai/settings/usage\" in msg\n+    # Must mention the subscription cycle reset (not generic 'add credits').\n+    assert \"reset\" in msg.lower()\n+    # Must still offer the provider-switch escape hatch.\n+    assert \"/model\" in msg\n+    # Model name should be interpolated.\n+    assert \"claude-opus-4-7\" in msg\n+\n+\n+def test_non_anthropic_billing_guidance_unaffected():\n+    \"\"\"A non-Anthropic provider keeps the generic billing guidance and does\n+    NOT get the Anthropic-specific claude.ai settings link.\"\"\"\n+    msg = _billing_or_entitlement_message(\n+        capability=\"model access\",\n+        provider=\"openrouter\",\n+        base_url=\"https://openrouter.ai/api/v1\",\n+        model=\"anthropic/claude-opus-4.7\",\n+    )\n+    assert \"claude.ai/settings/usage\" not in msg\n+    # Generic path still surfaces the OpenRouter credits link.\n+    assert \"openrouter.ai/settings/credits\" in msg"]},{"file":"tests/agent/test_error_classifier.py","hunks":["@@ -1295,6 +1295,25 @@ def test_400_with_billing_text(self):\n         result = classify_api_error(e)\n         assert result.reason == FailoverReason.billing\n \n+    def test_400_anthropic_extra_usage_exhausted(self):\n+        \"\"\"Anthropic returns 400 with 'out of extra usage' when the user's\n+        extra-usage allowance is depleted. Must classify as billing so the\n+        fallback chain engages (with credential rotation) instead of the\n+        generic format_error path, which never rotates. (#11736, #13170)\"\"\"\n+        e = MockAPIError(\n+            \"You're out of extra usage. Add more at claude.ai/settings/usage and keep going.\",\n+            status_code=400,\n+            body={\"error\": {\n+                \"type\": \"invalid_request_error\",\n+                \"message\": \"You're out of extra usage. Add more at claude.ai/settings/usage and keep going.\",\n+            }},\n+        )\n+        result = classify_api_error(e, provider=\"anthropic\")\n+        assert result.reason == FailoverReason.billing\n+        assert result.should_fallback is True\n+        assert result.retryable is False\n+        assert result.should_rotate_credential is True\n+\n     def test_200_with_error_body(self):\n         \"\"\"200 status with error in body — should be unknown, not crash.\"\"\"\n         class WeirdSuccess(Exception):\n"]}]},{"kind":"pr","number":56214,"title":"fix(provider): route api.anthropic.com to anthropic_messages (#32243)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","hermes_cli/runtime_provider.py","scripts/release.py","tests/hermes_cli/test_anthropic_oauth_routes_to_messages_api.py","tests/hermes_cli/test_detect_api_mode_for_url.py","tests/run_agent/test_provider_fallback.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -1241,7 +1241,17 @@ def try_activate_fallback(agent, reason: \"FailoverReason | None\" = None) -> bool\n         _fb_is_azure = agent._is_azure_openai_url(fb_base_url)\n         if fb_provider == \"openai-codex\":\n             fb_api_mode = \"codex_responses\"\n-        elif fb_provider == \"anthropic\" or fb_base_url.rstrip(\"/\").lower().endswith(\"/anthropic\"):\n+        elif (\n+            fb_provider == \"anthropic\"\n+            or fb_base_url.rstrip(\"/\").lower().endswith(\"/anthropic\")\n+            or base_url_hostname(fb_base_url) == \"api.anthropic.com\"\n+        ):\n+            # Custom providers (e.g. cron-anthropic) point at the native\n+            # api.anthropic.com host with no \"/anthropic\" path suffix, so the\n+            # name/suffix checks above miss them and they default to\n+            # chat_completions → POST /v1/chat/completions → 404. Match the\n+            # host the same way determine_api_mode() and _detect_api_mode_for_url()\n+            # do on the primary path. (#32243, #49247)\n             fb_api_mode = \"anthropic_messages\"\n         elif _fb_is_azure:\n             # Azure OpenAI serves gpt-5.x on /chat/completions — does NOT"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -93,6 +93,13 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:\n \n     - Direct api.openai.com endpoints need the Responses API for GPT-5.x\n       tool calls with reasoning (chat/completions returns 400).\n+    - Direct api.anthropic.com endpoints must use the native Messages\n+      API (``/v1/messages``).  Anthropic also exposes an OpenAI-compat\n+      ``/chat/completions`` shim on the same host, but Pro/Max OAuth\n+      subscriptions are only billed against the native Messages route;\n+      hitting the shim accounts against a separate \"extra usage\" pool\n+      that is empty by default and surfaces as HTTP 400 \"You're out of\n+      extra usage.\"  See issue #32243.\n     - Third-party Anthropic-compatible gateways (MiniMax, Zhipu GLM,\n       LiteLLM proxies, etc.) conventionally expose the native Anthropic\n       protocol under a ``/anthropic`` suffix — treat those as","@@ -108,6 +115,12 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:\n         return \"codex_responses\"\n     if hostname == \"api.openai.com\":\n         return \"codex_responses\"\n+    # Direct native Anthropic host: realign with providers.determine_api_mode,\n+    # which already maps this host to anthropic_messages. The exact-hostname\n+    # match rejects lookalike subdomains (api.anthropic.com.attacker.test) and\n+    # path-segment spoofing (proxy.test/api.anthropic.com/v1). (#32243)\n+    if hostname == \"api.anthropic.com\":\n+        return \"anthropic_messages\"\n     path = urlparse(normalized).path.rstrip(\"/\")\n     if path.endswith(\"/anthropic\") or path.endswith(\"/anthropic/v1\"):\n         return \"anthropic_messages\""]},{"file":"scripts/release.py","hunks":["@@ -1203,6 +1203,7 @@\n     \"[redacted:email]\": \"xiayh0107\",\n     \"[redacted:email]\": \"opriz\",\n     \"[redacted:email]\": \"xxxigm\",\n+    \"[redacted:email]\": \"x9x9x9x9x9x91\",\n     \"[redacted:email]\": \"NickLarcombe\",\n     \"[redacted:email]\": \"xxxigm\",\n     \"[redacted:email]\": \"anniesurla\","]},{"file":"tests/hermes_cli/test_anthropic_oauth_routes_to_messages_api.py","hunks":["@@ -0,0 +1,205 @@\n+\"\"\"Regression coverage for issue #32243.\n+\n+OAuth Pro/Max credentials must always reach Anthropic via the native\n+``/v1/messages`` endpoint, never the OpenAI-compat ``/chat/completions``\n+shim — the latter bills against a separate \"extra usage\" pool that\n+Pro/Max subscriptions don't fund, so any request that lands on it 400s\n+with \"You're out of extra usage\" the moment the gateway starts.\n+\n+The root cause was an inconsistency between two URL→api_mode helpers:\n+\n+* ``hermes_cli.providers.determine_api_mode`` correctly mapped\n+  ``api.anthropic.com`` to ``anthropic_messages``.\n+* ``hermes_cli.runtime_provider._detect_api_mode_for_url`` did NOT, so\n+  every code path that fell back to URL-only detection (named custom\n+  providers, direct-alias resolution, the api-key fallback inside\n+  ``resolve_runtime_provider``) returned ``None`` for that host and\n+  defaulted to ``chat_completions``.\n+\n+Exhaustive host-shape coverage for the helper itself lives in\n+``test_detect_api_mode_for_url.py::TestDirectAnthropicHost``.  The\n+tests below pin the **integration contract**: every runtime branch\n+that resolves an Anthropic endpoint must return\n+``api_mode == \"anthropic_messages\"``, so a future refactor of any\n+single branch cannot silently revert #32243.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from hermes_cli import runtime_provider as rp\n+\n+\n+class TestExplicitRuntimeForAnthropic:\n+    \"\"\"``_resolve_explicit_runtime`` with provider='anthropic' must\n+    always return ``api_mode='anthropic_messages'`` regardless of\n+    base_url shape or stale persisted ``model.api_mode`` values.\n+\n+    Exercised whenever the user (or a Hermes subcommand) passes an\n+    explicit ``--api-key`` / ``--base-url`` override to the runtime\n+    resolver.\n+    \"\"\"\n+\n+    def test_explicit_args_route_to_messages_api(self):\n+        result = rp._resolve_explicit_runtime(\n+            provider=\"anthropic\",\n+            requested_provider=\"anthropic\",\n+            model_cfg={},\n+            explicit_api_key=\"[redacted:secret-assignment]\",\n+            explicit_base_url=\"https://api.anthropic.com\",\n+        )\n+        assert result is not None\n+        assert result[\"api_mode\"] == \"anthropic_messages\"\n+        assert result[\"provider\"] == \"anthropic\"\n+        assert result[\"base_url\"] == \"https://api.anthropic.com\"\n+\n+    def test_stale_chat_completions_api_mode_in_config_is_ignored(self):\n+        # A user who previously had ``provider: openai`` and switched to\n+        # anthropic might still have ``model.api_mode: chat_completions``\n+        # in their config.yaml.  The anthropic branch must hard-pin\n+        # the mode — Anthropic's chat_completions shim is the bug\n+        # locus of #32243 and must never be reachable from this path.\n+        result = rp._resolve_explicit_runtime(\n+            provider=\"anthropic\",\n+            requested_provider=\"anthropic\",\n+            model_cfg={\"provider\": \"anthropic\", \"api_mode\": \"chat_completions\"},\n+            explicit_api_key=\"[redacted:secret-assignment]\",\n+            explicit_base_url=\"https://api.anthropic.com\",\n+        )\n+        assert result is not None\n+        assert result[\"api_mode\"] == \"anthropic_messages\"\n+\n+    def test_no_explicit_args_returns_none(self):\n+        # Guard the gating contract — _resolve_explicit_runtime only\n+        # fires when an explicit override is present; without one it\n+        # must return None so the caller falls through to the pool /\n+        # top-level anthropic branch.\n+        assert (\n+            rp._resolve_explicit_runtime(\n+                provider=\"anthropic\",\n+                requested_provider=\"anthropic\",\n+                model_cfg={\"provider\": \"anthropic\"},\n+            )\n+            is None\n+        )\n+\n+\n+class TestPoolEntryForAnthropic:\n+    \"\"\"``_resolve_runtime_from_pool_entry`` is what runs when a user\n+    has added an OAuth credential via ``hermes auth add anthropic\n+    --type oauth`` (the exact flow from #32243).  Pin the contract\n+    alongside the URL-detector test so all three runtime branches\n+    stay aligned and a future refactor of one cannot diverge from\n+    the others.\n+    \"\"\"\n+\n+    def test_oauth_pool_entry_routes_to_messages_api(self):\n+        class _Entry:\n+            access_token = \"[redacted:secret-assignment]\"\n+            runtime_api_key = \"[redacted:secret-assignment]\"\n+            source = \"manual:hermes_pkce\"\n+            base_url = \"https://api.anthropic.com\"\n+\n+        resolved = rp._resolve_runtime_from_pool_entry(\n+            provider=\"anthropic\",\n+            entry=_Entry(),\n+            requested_provider=\"anthropic\",\n+            model_cfg={\"provider\": \"anthropic\"},\n+        )\n+\n+        assert resolved[\"provider\"] == \"anthropic\"\n+        assert resolved[\"api_mode\"] == \"anthropic_messages\"\n+        assert resolved[\"base_url\"] == \"https://api.anthropic.com\"\n+\n+    def test_stale_chat_completions_api_mode_in_config_is_ignored(self):\n+        # Same regression as the explicit-runtime test above, but on\n+        # the pool path: a stale persisted chat_completions api_mode\n+        # must NOT override the provider-pin.\n+        class _Entry:\n+            access_token = \"[redacted:secret-assignment]\"\n+            runtime_api_key = \"[redacted:secret-assignment]\"\n+            source = \"manual:hermes_pkce\"\n+            base_url = \"https://api.anthropic.com\"\n+\n+        resolved = rp._resolve_runtime_from_pool_entry(\n+            provider=\"anthropic\",\n+            entry=_Entry(),\n+            requested_provider=\"anthropic\",\n+            model_cfg={\n+                \"provider\": \"anthropic\",\n+                \"api_mode\": \"chat_completions\",\n+            },\n+        )\n+\n+        assert resolved[\"api_mode\"] == \"anthropic_messages\"\n+\n+\n+class TestCustomProviderUrlFallback:\n+    \"\"\"The detector fix's actual reachable path: a user-defined\n+    ``providers:`` / ``custom_providers:`` entry whose ``api`` URL\n+    points at ``api.anthropic.com``, with no explicit ``api_mode`` /\n+    ``transport`` field.\n+\n+    Pre-fix: this falls through ``_try_resolve_from_custom_pool`` →\n+    ``_detect_api_mode_for_url(\"https://api.anthropic.com\")`` → None →\n+    default ``chat_completions`` → request lands on the OpenAI-compat\n+    shim → \"out of extra usage\" 400.\n+\n+    Post-fix: the detector returns ``anthropic_messages`` so the same\n+    config routes to ``/v1/messages`` where Pro/Max OAuth is billed.\n+    \"\"\"\n+\n+    def test_url_fallback_picks_messages_api(self, monkeypatch):\n+        class _Entry:\n+            access_token = \"[redacted:secret-assignment]\"\n+            runtime_api_key = \"[redacted:secret-assignment]\"\n+            source = \"custom-pool\"\n+\n+        class _Pool:\n+            def has_credentials(self):\n+                return True\n+\n+            def select(self):\n+                return _Entry()\n+\n+        monkeypatch.setattr(rp, \"get_custom_provider_pool_key\", lambda *a, **k: \"custom:my-claude\")\n+        monkeypatch.setattr(rp, \"load_pool\", lambda key: _Pool())\n+\n+        resolved = rp._try_resolve_from_custom_pool(\n+            \"https://api.anthropic.com\",\n+            \"custom\",\n+        )\n+\n+        assert resolved is not None\n+        assert resolved[\"api_mode\"] == \"anthropic_messages\"\n+\n+    def test_explicit_api_mode_override_still_wins(self, monkeypatch):\n+        # The detector is only consulted as a fallback — when the\n+        # custom-pool caller passes an explicit api_mode (e.g. from a\n+        # ``transport: chat_completions`` config entry), that takes\n+        # priority.  Pinned so the fix doesn't accidentally hijack a\n+        # user who DELIBERATELY pointed a chat_completions transport\n+        # at api.anthropic.com (uncommon but valid for OpenAI-compat\n+        # experiments).\n+        class _Entry:\n+            access_token = \"k\"\n+            runtime_api_key = \"k\"\n+            source = \"x\"\n+\n+        class _Pool:\n+            def has_credentials(self):\n+                return True\n+\n+            def select(self):\n+                return _Entry()\n+\n+        monkeypatch.setattr(rp, \"get_custom_provider_pool_key\", lambda *a, **k: \"custom:my-claude\")\n+        monkeypatch.setattr(rp, \"load_pool\", lambda key: _Pool())\n+\n+        resolved = rp._try_resolve_from_custom_pool(\n+            \"https://api.anthropic.com\",\n+            \"custom\",\n+            api_mode_override=\"chat_completions\",\n+        )\n+\n+        assert resolved is not None\n+        assert resolved[\"api_mode\"] == \"chat_completions\""]},{"file":"tests/hermes_cli/test_detect_api_mode_for_url.py","hunks":["@@ -1,10 +1,15 @@\n \"\"\"Tests for hermes_cli.runtime_provider._detect_api_mode_for_url.\n \n-The helper maps base URLs to api_modes for three cases:\n-  * api.openai.com  → codex_responses\n-  * api.x.ai        → codex_responses\n-  * */anthropic     → anthropic_messages (third-party gateways like MiniMax,\n-                                          Zhipu GLM, LiteLLM proxies)\n+The helper maps base URLs to api_modes for four cases:\n+  * api.openai.com    → codex_responses\n+  * api.x.ai          → codex_responses\n+  * api.anthropic.com → anthropic_messages (Pro/Max OAuth is only billed\n+                                            against /v1/messages; the\n+                                            chat_completions shim counts\n+                                            against a separate empty\n+                                            \"extra usage\" pool, see #32243)\n+  * */anthropic       → anthropic_messages (third-party gateways like MiniMax,\n+                                            Zhipu GLM, LiteLLM proxies)\n \n Consolidating the /anthropic detection in this helper (instead of three\n inline ``endswith`` checks spread across _resolve_runtime_from_pool_entry,","@@ -38,6 +43,49 @@ def test_xai_host_suffix_does_not_match(self):\n         assert _detect_api_mode_for_url(\"https://api.x.ai.example/v1\") is None\n \n \n+class TestDirectAnthropicHost:\n+    \"\"\"Native api.anthropic.com → /v1/messages. Pinned for issue #32243.\n+\n+    The Anthropic OpenAI-compat ``/chat/completions`` shim on the same\n+    host bills against a separate \"extra usage\" pool that Pro/Max OAuth\n+    subscriptions don't fund, so a fresh OAuth credential 400s with\n+    \"out of extra usage\" the moment a request lands there. The detector\n+    must keep ``api.anthropic.com`` on the native Messages API.\n+    \"\"\"\n+\n+    def test_bare_host(self):\n+        assert _detect_api_mode_for_url(\"https://api.anthropic.com\") == \"anthropic_messages\"\n+\n+    def test_with_trailing_slash(self):\n+        assert _detect_api_mode_for_url(\"https://api.anthropic.com/\") == \"anthropic_messages\"\n+\n+    def test_with_v1_suffix(self):\n+        # The Anthropic SDK appends /v1/messages itself but the user's\n+        # config may persist the /v1 form — must still resolve.\n+        assert _detect_api_mode_for_url(\"https://api.anthropic.com/v1\") == \"anthropic_messages\"\n+\n+    def test_uppercase_host_tolerated(self):\n+        assert _detect_api_mode_for_url(\"https://API.ANTHROPIC.COM/v1\") == \"anthropic_messages\"\n+\n+    def test_lookalike_subdomain_does_not_match(self):\n+        # ``api.anthropic.com.attacker.test`` is an attacker-controlled\n+        # host; the registrable label is ``attacker``, not Anthropic.\n+        # Must NOT be routed to anthropic_messages — leaking an\n+        # Anthropic OAuth token there is the worst case.\n+        assert (\n+            _detect_api_mode_for_url(\"https://api.anthropic.com.attacker.test/v1\")\n+            is None\n+        )\n+\n+    def test_anthropic_path_segment_does_not_match(self):\n+        # A reverse proxy under an unrelated host whose path *contains*\n+        # ``api.anthropic.com`` should not be classified as native.\n+        assert (\n+            _detect_api_mode_for_url(\"https://proxy.example.test/api.anthropic.com/v1\")\n+            is None\n+        )\n+\n+\n class TestAnthropicMessagesDetection:\n     \"\"\"Third-party gateways that speak the Anthropic protocol under /anthropic.\"\"\"\n "]},{"file":"tests/run_agent/test_provider_fallback.py","hunks":["@@ -182,6 +182,35 @@ def test_resolves_key_env_for_fallback_provider(self):\n             assert agent._try_activate_fallback() is True\n             assert mock_rpc.call_args.kwargs[\"explicit_api_key\"] == \"env-secret\"\n \n+    def test_anthropic_host_custom_provider_uses_anthropic_messages(self):\n+        \"\"\"A custom provider on the native api.anthropic.com host (no\n+        \"/anthropic\" path suffix, name != \"anthropic\") must resolve to the\n+        anthropic_messages wire protocol — not default to chat_completions,\n+        which POSTs /v1/chat/completions and 404s. Mirrors the primary-path\n+        determine_api_mode() host check.\"\"\"\n+        fbs = [\n+            {\n+                \"provider\": \"cron-anthropic\",\n+                \"model\": \"claude-sonnet-4-6\",\n+                \"base_url\": \"https://api.anthropic.com\",\n+                \"key_env\": \"MY_FALLBACK_KEY\",\n+            }\n+        ]\n+        agent = _make_agent(fallback_model=fbs)\n+        with (\n+            patch.dict(\"os.environ\", {\"MY_FALLBACK_KEY\": \"env-secret\"}, clear=False),\n+            patch(\n+                \"agent.auxiliary_client.resolve_provider_client\",\n+                return_value=(\n+                    _mock_client(base_url=\"https://api.anthropic.com\"),\n+                    \"claude-sonnet-4-6\",\n+                ),\n+            ),\n+            patch(\"hermes_cli.model_normalize.normalize_model_for_provider\", side_effect=lambda m, p: m),\n+        ):\n+            assert agent._try_activate_fallback() is True\n+            assert agent.api_mode == \"anthropic_messages\"\n+\n \n # ── Pool-rotation vs fallback gating (#11314) ────────────────────────────\n \n"]}]},{"kind":"pr","number":58560,"title":"fix: prefer explicit anthropic api key","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#77923","number":77923}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":76669,"title":"fix(anthropic): alias session search on OAuth wire","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#76807","number":76807},{"kind":"pr","value":"#65365","number":65365}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":76807,"title":"fix(anthropic): alias the session_search and memory schemas on the OAuth wire","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b70a4e7533dce506a41f847b3fc082d6b6fe4a9b","number":0},{"kind":"commit","value":"ea9e8d6e8c80fbdd0b0b4864b833d2bfd3b429bb","number":0},{"kind":"commit","value":"e00800fc89e761382326c3a5eef8f4cccfbbc205","number":0},{"kind":"commit","value":"a344c9205","number":0},{"kind":"commit","value":"a2251b40e","number":0},{"kind":"commit","value":"9efe01c3a","number":0},{"kind":"commit","value":"1c9f83ec8f6e","number":0},{"kind":"pr","value":"#6498","number":6498},{"kind":"pr","value":"#56128","number":56128},{"kind":"pr","value":"#6854","number":6854},{"kind":"pr","value":"#10576","number":10576},{"kind":"pr","value":"#17681","number":17681},{"kind":"pr","value":"#19260","number":19260},{"kind":"pr","value":"#21019","number":21019},{"kind":"pr","value":"#24250","number":24250},{"kind":"pr","value":"#26960","number":26960},{"kind":"pr","value":"#6427","number":6427},{"kind":"pr","value":"#12229","number":12229},{"kind":"pr","value":"#28872","number":28872},{"kind":"pr","value":"#47723","number":47723},{"kind":"pr","value":"#32318","number":32318},{"kind":"pr","value":"#56214","number":56214},{"kind":"pr","value":"#40020","number":40020},{"kind":"pr","value":"#56128","number":56128},{"kind":"pr","value":"#40073","number":40073},{"kind":"pr","value":"#56128","number":56128},{"kind":"pr","value":"#46687","number":46687},{"kind":"pr","value":"#47723","number":47723},{"kind":"pr","value":"#48177","number":48177},{"kind":"pr","value":"#48202","number":48202},{"kind":"pr","value":"#48177","number":48177},{"kind":"pr","value":"#69844","number":69844},{"kind":"pr","value":"#48177","number":48177},{"kind":"pr","value":"#70054","number":70054},{"kind":"pr","value":"#72173","number":72173},{"kind":"pr","value":"#72263","number":72263},{"kind":"pr","value":"#62008","number":62008},{"kind":"pr","value":"#75697","number":75697},{"kind":"pr","value":"#76669","number":76669},{"kind":"pr","value":"#76807","number":76807},{"kind":"pr","value":"#76807","number":76807},{"kind":"pr","value":"#6498","number":6498},{"kind":"pr","value":"#21019","number":21019},{"kind":"pr","value":"#40020","number":40020},{"kind":"pr","value":"#40073","number":40073},{"kind":"pr","value":"#56128","number":56128},{"kind":"pr","value":"#17681","number":17681},{"kind":"pr","value":"#46687","number":46687},{"kind":"pr","value":"#28872","number":28872},{"kind":"pr","value":"#47723","number":47723},{"kind":"pr","value":"#48177","number":48177},{"kind":"pr","value":"#48202","number":48202},{"kind":"pr","value":"#69844","number":69844},{"kind":"pr","value":"#76669","number":76669},{"kind":"pr","value":"#76807","number":76807},{"kind":"pr","value":"#72263","number":72263},{"kind":"pr","value":"#62008","number":62008},{"kind":"pr","value":"#75697","number":75697},{"kind":"pr","value":"#76807","number":76807},{"kind":"pr","value":"#72173","number":72173},{"kind":"pr","value":"#69844","number":69844},{"kind":"pr","value":"#72263","number":72263},{"kind":"pr","value":"#69844","number":69844},{"kind":"pr","value":"#48177","number":48177},{"kind":"pr","value":"#6498","number":6498},{"kind":"pr","value":"#6854","number":6854},{"kind":"pr","value":"#10576","number":10576},{"kind":"pr","value":"#17681","number":17681},{"kind":"pr","value":"#19260","number":19260},{"kind":"pr","value":"#21019","number":21019},{"kind":"pr","value":"#24250","number":24250},{"kind":"pr","value":"#26960","number":26960},{"kind":"pr","value":"#28872","number":28872},{"kind":"pr","value":"#32318","number":32318},{"kind":"pr","value":"#40020","number":40020},{"kind":"pr","value":"#40073","number":40073},{"kind":"pr","value":"#46687","number":46687},{"kind":"pr","value":"#48177","number":48177},{"kind":"pr","value":"#48202","number":48202},{"kind":"pr","value":"#70054","number":70054},{"kind":"pr","value":"#76669","number":76669},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#65365","number":65365},{"kind":"pr","value":"#76669","number":76669},{"kind":"pr","value":"#76807","number":76807}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":77923,"title":"fix: prefer explicit anthropic api key","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","tests/agent/test_anthropic_adapter.py"]},{"kind":"pr","number":78025,"title":"fix(anthropic): alias session_search/memory OAuth billing-classifier triggers (fixes #65365, #82154)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"18a76be124","number":0},{"kind":"pr","value":"#100173","number":100173},{"kind":"pr","value":"#86677","number":86677},{"kind":"pr","value":"#65365","number":65365}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":86677,"title":"fix(agent): reword SKILLS_GUIDANCE trigger and stop mislabelling its 400 as billing","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","agent/conversation_loop.py","agent/credential_pool.py","agent/error_classifier.py","agent/prompt_builder.py","hermes_cli/auth.py","run_agent.py","tests/agent/test_anthropic_billing_guidance.py","tests/agent/test_billing_unverified_carrythrough.py","tests/agent/test_credential_pool_sole_cooldown.py","tests/agent/test_error_classifier.py","tests/agent/test_skills_guidance_content_filter.py","website/docs/user-guide/features/skills.md"]},{"kind":"pr","number":100173,"title":"fix(anthropic): alias session_search/memory OAuth billing-classifier triggers (salvage #78025)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/transports/anthropic.py","tests/agent/test_anthropic_mcp_prefix_strip.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}