Skip to content
Closed
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
84 changes: 83 additions & 1 deletion agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import logging
import os
import platform
import re
import secrets
import stat
import subprocess
Expand Down Expand Up @@ -395,6 +396,62 @@ def _detect_claude_code_version() -> str:
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
_MCP_TOOL_PREFIX = "mcp__"

# Anthropic's OAuth billing classifier fingerprints certain Hermes tool schemas
# as a third-party app and reroutes the request to the metered extra-usage lane,
# surfacing as HTTP 400 "You're out of extra usage" on a valid subscription
# token. Issue #65365 isolated two triggers with a deterministic A/B repro on a
# live Claude Max account: exposing the ``session_search`` schema alone, or the
# ``memory`` schema alone, each reproduces the 400; removing them clears it.
#
# Both are aliased to neutral names on the OAuth wire only. The response
# transport restores the registry name before dispatch, so tool behavior and
# API-key requests are unchanged.
_OAUTH_TOOL_NAME_ALIASES = {
"session_search": "chat_history_lookup",
"memory": "context_notes",
}
_OAUTH_TOOL_NAME_REVERSE_ALIASES = {
wire_name: name for name, wire_name in _OAUTH_TOOL_NAME_ALIASES.items()
}

# Aliases that are ALSO safe to substitute in free-form prose (system prompt
# text, tool descriptions). Only unambiguous snake_case tool tokens qualify:
# "memory" is ordinary English throughout the system prompt ("persistent
# memory across sessions", "OS, CPU, memory, disk") and inside the memory
# tool's own description and parameter docs, so rewriting it in prose would
# corrupt guidance the model has to follow — including the ``target`` enum
# values it must emit. Renaming a tool is a different operation from
# rewriting the vocabulary that describes it; keeping the sets separate is
# what lets the next alias be name-only. A model that follows unaliased
# prose and calls ``memory`` still dispatches: normalize_response resolves
# the bare name through the registry.
_OAUTH_PROSE_ALIAS_NAMES = frozenset({"session_search"})

# Word-boundary matchers so a prose substitution can't corrupt a longer
# identifier that merely CONTAINS the token. System blocks carry user-supplied
# text (project AGENTS.md / .cursorrules, memory snapshots), and a bare
# str.replace would turn a reference like ``tools/session_search_tool.py``
# into ``tools/chat_history_lookup_tool.py`` — a path that does not exist and
# has no reverse mapping. ``\b`` treats ``_`` as a word char, so the longer
# identifier is skipped while ``session_search``, `` `session_search` `` and
# ``session_search(`` still match.
#
# sorted() is load-bearing, not decoration: iterating a frozenset directly
# yields hash-seed-dependent order, so with two or more prose aliases the
# rewritten system bytes would differ between processes and silently break
# prompt caching in a way that is very hard to reproduce. Do not "simplify".
_OAUTH_PROSE_ALIAS_PATTERNS = tuple(
(re.compile(rf"\b{re.escape(name)}\b"), _OAUTH_TOOL_NAME_ALIASES[name])
for name in sorted(_OAUTH_PROSE_ALIAS_NAMES)
)


def _apply_oauth_prose_aliases(text: str) -> str:
"""Rewrite prose-safe tool tokens to their OAuth wire aliases."""
for pattern, wire_name in _OAUTH_PROSE_ALIAS_PATTERNS:
text = pattern.sub(wire_name, text)
return text


def _get_claude_code_version() -> str:
"""Lazily detect the installed Claude Code version when OAuth headers need it."""
Expand Down Expand Up @@ -2789,6 +2846,7 @@ def build_anthropic_kwargs(
text = text.replace("Hermes agent", "Claude Code")
text = text.replace("hermes-agent", "claude-code")
text = text.replace("Nous Research", "Anthropic")
text = _apply_oauth_prose_aliases(text)
block["text"] = text

# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
Expand All @@ -2809,18 +2867,42 @@ def build_anthropic_kwargs(
# so any session with an MCP server configured still tripped the
# classifier. normalize_response reverses both forms via registry
# lookup so the dispatcher still sees the original name. GH-25255.
def _to_oauth_wire_name(name: str) -> str:
def _to_oauth_wire_name(name: str, *, allow_alias: bool = True) -> str:
if allow_alias and name in _OAUTH_TOOL_NAME_ALIASES:
aliased = _OAUTH_TOOL_NAME_ALIASES[name]
# Skip the alias when a real tool already owns that wire name
# (see _claimed_wire_names below) — a duplicate name is a hard
# 400 that would break every request.
if _MCP_TOOL_PREFIX + aliased not in _claimed_wire_names:
name = aliased
if name.startswith("mcp__"):
return name # already correct, don't double-prefix
if name.startswith("mcp_"):
# single-underscore native MCP tool -> promote to double
return "mcp__" + name[len("mcp_"):]
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>

# Wire names owned by tools that are NOT alias sources. An alias must
# never collide with one: two identical tool names in a single request
# is a hard 400 from Anthropic, which would break every call —
# strictly worse than the bug being fixed. This mirrors the inbound
# "registered tool wins" rule in normalize_response, so the outbound
# and inbound sides agree on who owns a contested name.
_claimed_wire_names = {
_to_oauth_wire_name(tool["name"], allow_alias=False)
for tool in (anthropic_tools or [])
if isinstance(tool.get("name"), str)
and tool["name"] not in _OAUTH_TOOL_NAME_ALIASES
}

if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool:
tool["name"] = _to_oauth_wire_name(tool["name"])
description = tool.get("description")
if isinstance(description, str):
# Prose-safe aliases only — see _OAUTH_PROSE_ALIAS_NAMES.
tool["description"] = _apply_oauth_prose_aliases(description)

# 4. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
Expand Down
17 changes: 14 additions & 3 deletions agent/transports/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block
from agent.anthropic_adapter import (
_OAUTH_TOOL_NAME_REVERSE_ALIASES,
_sanitize_replay_block,
_to_plain_data,
)
from agent.transports.types import ToolCall

strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
Expand Down Expand Up @@ -143,14 +147,21 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
# Resolve by registry lookup, preferring whichever original
# is actually registered; never rewrite a name the LLM used
# that already resolves natively. GH-25255.
bare = name[len(_MCP_PREFIX):] # read_file
from tools.registry import registry as _tool_registry
if not _tool_registry.get_entry(name):
bare = name[len(_MCP_PREFIX):] # read_file
single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
if _tool_registry.get_entry(single):
name = single
elif _tool_registry.get_entry(bare):
name = bare
elif bare in _OAUTH_TOOL_NAME_REVERSE_ALIASES:
# OAuth wire alias (e.g. chat_history_lookup ->
# session_search). Checked LAST so the GH-25255
# contract still holds: a real tool actually
# registered under the wire name wins, and we never
# rewrite a name that already resolves natively.
name = _OAUTH_TOOL_NAME_REVERSE_ALIASES[bare]
tool_calls.append(
ToolCall(
id=block.id,
Expand Down
Loading
Loading