Skip to content
Open
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
8 changes: 8 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1429,10 +1429,18 @@ def init_agent(
agent._tool_snapshot_generation = _snapshot_registry._generation
except Exception:
agent._tool_snapshot_generation = 0
# Native-Anthropic OAuth (Claude Pro/Max subscription) sessions must not
# send the full ~60-tool core set eagerly — Anthropic's OAuth billing
# classifier routes such requests to "extra usage" instead of plan quota
# based partly on the tool-schema footprint. Shrink the always-eager set
# to toolsets.OAUTH_SAFE_CORE_TOOLS and defer the rest behind the
# tool_search/describe/call bridge; every tool stays reachable, just not
# eagerly listed. See toolsets.OAUTH_SAFE_CORE_TOOLS docstring.
agent.tools = _ra().get_tool_definitions(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
quiet_mode=agent.quiet_mode,
oauth_minimal_core=bool(getattr(agent, "_is_anthropic_oauth", False)),
)

# Show tool configuration and store valid tool names for validation
Expand Down
2 changes: 1 addition & 1 deletion agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1854,7 +1854,7 @@ def dump_api_request_debug(
"reason": reason,
"request": {
"method": "POST",
"url": f"{agent.base_url.rstrip('/')}{'/responses' if agent.api_mode == 'codex_responses' else '/chat/completions'}",
"url": f"{agent.base_url.rstrip('/')}{'/responses' if agent.api_mode == 'codex_responses' else '/v1/messages' if agent.api_mode == 'anthropic_messages' else '/chat/completions'}",
"headers": {
"Authorization": f"Bearer {agent._mask_api_key_for_logs(api_key)}",
"Content-Type": "application/json",
Expand Down
82 changes: 77 additions & 5 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,8 @@ def _build_anthropic_client_with_bearer_hook(
# Same env-inference trap as build_anthropic_client: auth_token-only
# construction would otherwise also send ANTHROPIC_API_KEY as X-Api-Key.
client.api_key = None
# Mark the client as OAuth since Bearer/Entra flows are OAuth-authenticated
client._hermes_is_oauth = True
return client


Expand Down Expand Up @@ -909,6 +911,13 @@ def build_anthropic_client(
# key whenever we intentionally authenticated via auth_token alone.
if "auth_token" in kwargs and "api_key" not in kwargs:
client.api_key = None

# Mark the client if built from OAuth so downstream code can apply
# the necessary system-prompt sanitization and tool-name normalization
# to avoid Anthropic's billing classifier (GH-25255).
if _is_oauth_token(api_key):
client._hermes_is_oauth = True

return client


Expand Down Expand Up @@ -1395,7 +1404,25 @@ def _read_creds() -> Optional[Dict[str, Any]]:

# 3. Regular API key. An explicit user-configured key must not be shadowed
# by auto-discovered Claude Code or credential-pool OAuth credentials.
# However, if this source is explicitly suppressed (e.g. known-bad token),
# skip it and fall through to Claude Code credentials (priority #4).
api_key = _getenv("ANTHROPIC_API_KEY").strip()

# Check if env:ANTHROPIC_API_KEY is in suppressed_sources (via auth.json)
try:
import json
from pathlib import Path
auth_path = Path(get_hermes_home()) / "auth.json"
if auth_path.exists():
auth_data = json.loads(auth_path.read_text(encoding="utf-8"))
suppressed = auth_data.get("suppressed_sources", {}).get("anthropic", [])
if "env:ANTHROPIC_API_KEY" in suppressed and api_key:
# Skip this suppressed source and fall through to #4
logger.debug("Skipping suppressed ANTHROPIC_API_KEY source")
api_key = None
except Exception:
pass # If we can't read auth.json, don't let it block credential resolution

if api_key:
return api_key

Expand Down Expand Up @@ -2895,15 +2922,41 @@ def build_anthropic_kwargs(
else:
system = [cc_block]

# 2. Sanitize system prompt — replace product name references
# 2. Sanitize system prompt — replace ALL product-name references
# to avoid Anthropic's server-side content filters.
# Anthropic's OAuth billing classifier greps the entire request
# (system prompt + tool names + tool descriptions) for third-party
# fingerprints. The original sanitizer only replaced four exact
# patterns, leaving behind standalone "Hermes" references, bare
# "Nous" tokens (without the space in "Nous Research"), and the
# canonical ``hermes-agent.nousresearch.com`` documentation URL.
# Each of those is a strong classifier trigger that kept flipping
# OAuth requests into the extra-usage billing lane (HTTP 400
# "Third-party apps now draw from extra usage, not plan limits").
# GH-25255, GH-25256.
_OAUTH_TEXT_REPLACEMENTS = (
# Most specific patterns first — order matters because each
# replacement is applied in sequence and a shorter pattern
# could partially consume a longer one.
("hermes-agent.nousresearch.com", "claude.ai/docs"),
("hermes.nous", "claude.ai"),
("Hermes Agent", "Claude Code"),
("Hermes agent", "Claude Code"),
("Nous Research", "Anthropic"),
("Nous Portal", "Claude Code Portal"),
("Nous subscription", "Claude Code subscription"),
("Nous", "Anthropic"),
("hermes-agent", "claude-code"),
("hermes_agent", "claude_code"),
("nousresearch", "anthropic"),
("Hermes", "Claude Code"),
("nous", "anthropic"),
)
for block in system:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
text = text.replace("Hermes Agent", "Claude Code")
text = text.replace("Hermes agent", "Claude Code")
text = text.replace("hermes-agent", "claude-code")
text = text.replace("Nous Research", "Anthropic")
for _old, _new in _OAUTH_TEXT_REPLACEMENTS:
text = text.replace(_old, _new)
block["text"] = text

# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
Expand Down Expand Up @@ -2936,6 +2989,17 @@ def _to_oauth_wire_name(name: str) -> str:
for tool in anthropic_tools:
if "name" in tool:
tool["name"] = _to_oauth_wire_name(tool["name"])
# 3b. Sanitize tool descriptions — the billing classifier
# greps the entire request body (not just system + tool
# names) for third-party fingerprints. Tool descriptions
# are riddled with "Hermes" / "Nous" references that
# were only being replaced in the system prompt, leaving
# the tool schema as a live classifier trigger.
_desc = tool.get("description")
if isinstance(_desc, str) and _desc:
for _old, _new in _OAUTH_TEXT_REPLACEMENTS:
_desc = _desc.replace(_old, _new)
tool["description"] = _desc

# 4. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
Expand All @@ -2949,6 +3013,14 @@ def _to_oauth_wire_name(name: str) -> str:
elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name

# 5. Normalize a specific tool_choice name so it matches the
# OAuth-wire tool names above. Without this, a forced
# ``tool_choice="read_file"`` stays bare on the wire while the
# tool list carries ``mcp__read_file``, so Anthropic rejects the
# request (the chosen name doesn't match any available tool).
if isinstance(tool_choice, str) and not tool_choice.startswith(("mcp__", "auto", "required", "none")):
tool_choice = _to_oauth_wire_name(tool_choice)

kwargs: Dict[str, Any] = {
"model": model,
"messages": anthropic_messages,
Expand Down
3 changes: 2 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,7 @@ def _maybe_wrap_anthropic(
api_key: str,
base_url: str,
api_mode: Optional[str] = None,
is_oauth: bool = False,
) -> Any:
"""Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when
the endpoint actually speaks Anthropic Messages.
Expand Down Expand Up @@ -2073,7 +2074,7 @@ def _maybe_wrap_anthropic(
model, base_url[:60] if base_url else "", api_mode or "auto-detected",
)
return AnthropicAuxiliaryClient(
real_client, model, api_key, base_url, is_oauth=False,
real_client, model, api_key, base_url, is_oauth=is_oauth,
)


Expand Down
20 changes: 18 additions & 2 deletions agent/transports/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,23 @@ def build_kwargs(
fast_mode: bool
drop_context_1m_beta: bool
"""
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.anthropic_adapter import build_anthropic_kwargs, _is_oauth_token

# Detect if this client was built from an OAuth token.
# Check three sources of truth in order of preference:
# 1. Explicit is_oauth in params (set by agent initialization)
# 2. Marker added to client at creation time (_hermes_is_oauth)
# 3. Auto-detect from the client's auth_token if available
is_oauth = params.get("is_oauth")
if is_oauth is None:
# Check if the client was marked as OAuth when created
is_oauth = getattr(self.client, "_hermes_is_oauth", False)
if not is_oauth:
# Last resort: check if the client's auth_token looks like OAuth
# (This handles cases where the marker wasn't set)
auth_token = getattr(self.client, "auth_token", None) or getattr(self.client, "_auth_token", None)
if auth_token and isinstance(auth_token, str):
is_oauth = _is_oauth_token(auth_token)

return build_anthropic_kwargs(
model=model,
Expand All @@ -69,7 +85,7 @@ def build_kwargs(
max_tokens=params.get("max_tokens", 16384),
reasoning_config=params.get("reasoning_config"),
tool_choice=params.get("tool_choice"),
is_oauth=params.get("is_oauth", False),
is_oauth=is_oauth,
preserve_dots=params.get("preserve_dots", False),
context_length=params.get("context_length"),
base_url=params.get("base_url"),
Expand Down
25 changes: 24 additions & 1 deletion model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ def get_tool_definitions(
disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False,
skip_tool_search_assembly: bool = False,
oauth_minimal_core: bool = False,
) -> List[Dict[str, Any]]:
"""
Get tool definitions for model API calls with toolset-based filtering.
Expand All @@ -322,6 +323,16 @@ def get_tool_definitions(
tool_search / tool_describe bridge handlers so they can read the
real catalog, not the already-collapsed one. Public callers should
leave this False.
oauth_minimal_core: When True, shrink tool_search's always-eager
"core" allowlist down to ``toolsets.OAUTH_SAFE_CORE_TOOLS`` and
force the tool_search/describe/call bridge to activate
regardless of the normal context-percentage gate. Set by
agent_init.py for native-Anthropic OAuth (Claude Pro/Max
subscription) sessions, where sending the full ~60-tool core set
on every turn trips Anthropic's OAuth billing classifier and
routes the request to "extra usage" instead of plan quota. All
tools stay reachable via the bridge — this only changes what's
eagerly listed vs. discoverable on demand.

Returns:
Filtered list of OpenAI-format tool definitions.
Expand Down Expand Up @@ -352,6 +363,7 @@ def get_tool_definitions(
cfg_fp,
bool(os.environ.get("HERMES_KANBAN_TASK")),
bool(skip_tool_search_assembly),
bool(oauth_minimal_core),
_is_delegated_child_context(),
_is_dispatcher_owned_worker(),
profile_scope,
Expand All @@ -367,7 +379,8 @@ def get_tool_definitions(
return list(cached)

result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode,
skip_tool_search_assembly=skip_tool_search_assembly)
skip_tool_search_assembly=skip_tool_search_assembly,
oauth_minimal_core=oauth_minimal_core)
if quiet_mode and cache_key is not None:
# Cache the freshly-computed list, but hand callers a shallow copy so
# downstream mutations (e.g. run_agent appending memory/LCM tool
Expand All @@ -393,6 +406,7 @@ def _compute_tool_definitions(
disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False,
skip_tool_search_assembly: bool = False,
oauth_minimal_core: bool = False,
) -> List[Dict[str, Any]]:
"""Uncached implementation of :func:`get_tool_definitions`."""
# Determine which tool names the caller wants
Expand Down Expand Up @@ -588,10 +602,19 @@ def _compute_tool_definitions(
ts_cfg = _load_ts_config()
if not skip_tool_search_assembly and ts_cfg.enabled != "off":
context_length = _resolve_active_context_length()
_core_override = None
if oauth_minimal_core:
try:
from toolsets import OAUTH_SAFE_CORE_TOOLS
_core_override = frozenset(OAUTH_SAFE_CORE_TOOLS)
except Exception:
logger.warning("oauth_minimal_core requested but OAUTH_SAFE_CORE_TOOLS unavailable")
assembly = assemble_tool_defs(
filtered_tools,
context_length=context_length,
config=ts_cfg,
core_override=_core_override,
force_activate=oauth_minimal_core,
)
if assembly.activated and not quiet_mode:
_forms = {"full": "catalog listing embedded",
Expand Down
Loading