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
93 changes: 93 additions & 0 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2597,6 +2597,42 @@ def convert_messages_to_anthropic(
return system, result


_DEFAULT_OAUTH_SYSTEM_BUDGET_CHARS = 3000


def _resolve_oauth_system_budget() -> int:
"""Character budget for Hermes-specific system text on the OAuth wire.

Behavioral setting, so it lives in ``config.yaml`` (``.env`` is for
secrets only, per AGENTS.md)::

providers:
anthropic:
oauth_system_budget_chars: 3000 # 0 disables the trim

Returns the default (3000) when unset, ``0`` when explicitly disabled.
Never raises: an unreadable config or a malformed value falls back to the
default so a typo cannot break request building.
"""
try:
from hermes_cli.config import load_config_readonly

cfg = load_config_readonly() or {}
providers = cfg.get("providers")
if isinstance(providers, dict):
anthropic_cfg = providers.get("anthropic")
if isinstance(anthropic_cfg, dict):
raw = anthropic_cfg.get("oauth_system_budget_chars")
if raw is not None:
budget = int(raw)
return budget if budget > 0 else 0
except Exception:
# Config unreadable, malformed value, or hermes_cli unavailable in a
# trimmed environment — fall through to the default.
pass
return _DEFAULT_OAUTH_SYSTEM_BUDGET_CHARS


def build_anthropic_kwargs(
model: str,
messages: List[Dict],
Expand Down Expand Up @@ -2693,6 +2729,63 @@ def build_anthropic_kwargs(
text = text.replace("Nous Research", "Anthropic")
block["text"] = text

# 2b. Cap Hermes-specific system text on OAuth/subscription auth.
# Anthropic's OAuth billing classifier inspects the system prompt.
# A large block of app-specific instructions (skill_manage,
# session_search, Computer Use guidance, mid-turn steering, …) is
# fingerprinted as raw API usage, so the request is billed against
# the pay-per-token "extra usage" pool instead of the subscription's
# included quota — surfacing as HTTP 400 "You're out of extra usage"
# / HTTP 429 "monthly spend limit" while the plan still has headroom
# (established by bisection: generic filler of identical length
# passes; only the app-specific text flips the lane).
#
# Same class of problem as the ``mcp_`` -> ``mcp__`` tool-name
# normalization in step 3 below: payload SHAPE, not actual usage,
# decides the billing lane.
#
# Keep the Claude Code identity block (index 0) intact and trim the
# remaining system text so the request stays on included billing.
# Budget: ``providers.anthropic.oauth_system_budget_chars`` in
# config.yaml (0 disables) — see _resolve_oauth_system_budget.
_sys_budget = _resolve_oauth_system_budget()
if _sys_budget > 0 and isinstance(system, list) and len(system) > 1:
_remaining = _sys_budget
_kept = [system[0]]
_dropped_cc = None
for _blk in system[1:]:
if not (isinstance(_blk, dict) and _blk.get("type") == "text"):
_kept.append(_blk)
continue
_t = _blk.get("text", "")
if len(_t) <= _remaining:
_remaining -= len(_t)
_kept.append(_blk)
continue
# Trim at the last paragraph/line break before the budget so
# we don't cut mid-sentence; fall back to a hard cut.
_cut = _t.rfind("\n", 0, _remaining)
if _cut < _remaining // 2:
_cut = _remaining
_trimmed = _t[:_cut].rstrip()
_remaining = 0
if _trimmed:
_blk["text"] = _trimmed
_kept.append(_blk)
elif isinstance(_blk.get("cache_control"), dict) and _dropped_cc is None:
_dropped_cc = dict(_blk["cache_control"])
# Blocks trimmed to "" must not stay on the wire: Anthropic 400s
# on empty text blocks, and empty text + cache_control is always
# invalid ("system.N: cache_control cannot be set for empty text
# blocks"). If a dropped block carried the cache marker, move it
# to the last surviving text block so the prefix stays cacheable.
if _dropped_cc is not None:
for _blk in reversed(_kept):
if isinstance(_blk, dict) and _blk.get("type") == "text" and _blk.get("text"):
_blk.setdefault("cache_control", _dropped_cc)
break
system = _kept

# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
# billing classifier treats a single-underscore ``mcp_`` tool name as
Expand Down
2 changes: 2 additions & 0 deletions contributors/emails/manuelguttmann@icloud.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mguttmann
# PR #72173
227 changes: 227 additions & 0 deletions tests/agent/test_anthropic_oauth_system_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""Tests for the Anthropic OAuth system-prompt budget (GH-65564).

Anthropic's subscription/OAuth billing classifier inspects the system prompt.
A large block of Hermes-specific instructions (skill_manage, session_search,
Computer Use guidance, mid-turn steering, ...) is fingerprinted as raw API
usage, so the request is billed against the pay-per-token "extra usage" pool
instead of the subscription's included quota. Once that pool is empty the API
answers:

HTTP 400 "You're out of extra usage. Add more at claude.ai/settings/usage"
HTTP 429 "monthly spend limit"

...even though the Claude Pro/Max subscription still has plenty of headroom.
This is the same class of problem as the ``mcp_`` -> ``mcp__`` tool-name
normalization (GH-25255, see test_anthropic_mcp_prefix_strip.py): the payload
*shape*, not the actual usage, decides the billing lane.

The mitigation trims the Hermes-specific portion of the system prompt on the
OAuth wire to a budget configured in ``config.yaml``:

providers:
anthropic:
oauth_system_budget_chars: 3000 # 0 disables

Invariants covered here:

1. The Claude Code identity block (index 0) is never trimmed.
2. The Hermes portion is capped at the configured budget.
3. Budget 0 disables the behavior entirely (upstream semantics).
4. Short prompts are passed through untouched.
5. No empty text block is ever left on the wire — Anthropic 400s on those,
and an empty block carrying ``cache_control`` is *always* rejected
("cache_control cannot be set for empty text blocks").
6. Prompt caching survives: a dropped block's ``cache_control`` marker is
migrated to the last surviving text block.
7. API-key (non-OAuth) requests are never trimmed — they bill per token.
"""

from __future__ import annotations

from unittest.mock import patch

import pytest


IDENTITY_MARKER = "Claude Code"
DEFAULT_BUDGET = 3000


def _config(budget) -> dict:
"""Build a config.yaml-shaped dict carrying the budget setting."""
if budget is None:
return {}
return {"providers": {"anthropic": {"oauth_system_budget_chars": budget}}}


def _build(system_text: str, budget=None, *, is_oauth: bool = True):
"""Build Anthropic kwargs with a patched config.yaml value."""
from agent.anthropic_adapter import build_anthropic_kwargs

with patch(
"hermes_cli.config.load_config_readonly",
return_value=_config(budget),
):
return build_anthropic_kwargs(
model="claude-sonnet-4-5",
messages=[
{"role": "system", "content": system_text},
{"role": "user", "content": "hello"},
],
tools=None,
max_tokens=1024,
reasoning_config=None,
is_oauth=is_oauth,
)


def _hermes_len(system) -> int:
"""Length of the system text excluding the Claude Code identity block."""
if isinstance(system, str):
return len(system)
texts = [
b.get("text", "")
for b in system
if isinstance(b, dict) and b.get("type") == "text"
]
return sum(len(t) for t in texts[1:])


class TestOAuthSystemBudgetResolution:
"""The budget comes from config.yaml, not from an environment variable."""

def test_reads_budget_from_config_yaml(self):
from agent.anthropic_adapter import _resolve_oauth_system_budget

with patch(
"hermes_cli.config.load_config_readonly",
return_value=_config(1234),
):
assert _resolve_oauth_system_budget() == 1234

def test_defaults_when_unset(self):
from agent.anthropic_adapter import _resolve_oauth_system_budget

with patch("hermes_cli.config.load_config_readonly", return_value={}):
assert _resolve_oauth_system_budget() == DEFAULT_BUDGET

def test_zero_disables(self):
from agent.anthropic_adapter import _resolve_oauth_system_budget

with patch(
"hermes_cli.config.load_config_readonly",
return_value=_config(0),
):
assert _resolve_oauth_system_budget() == 0

def test_malformed_value_falls_back_to_default(self):
"""A typo in config.yaml must not break request building."""
from agent.anthropic_adapter import _resolve_oauth_system_budget

with patch(
"hermes_cli.config.load_config_readonly",
return_value=_config("not-a-number"),
):
assert _resolve_oauth_system_budget() == DEFAULT_BUDGET

def test_unreadable_config_falls_back_to_default(self):
from agent.anthropic_adapter import _resolve_oauth_system_budget

with patch(
"hermes_cli.config.load_config_readonly",
side_effect=OSError("config.yaml unreadable"),
):
assert _resolve_oauth_system_budget() == DEFAULT_BUDGET


class TestOAuthSystemBudgetTrim:
"""The Hermes portion of the system prompt is capped on the OAuth wire."""

def test_long_system_prompt_is_trimmed_to_budget(self):
kwargs = _build("Hermes instructions. " * 800, budget=DEFAULT_BUDGET)

assert isinstance(kwargs["system"], list)
assert _hermes_len(kwargs["system"]) <= DEFAULT_BUDGET

def test_claude_code_identity_block_is_preserved(self):
"""Block 0 carries the Claude Code identity and must survive intact."""
kwargs = _build("Hermes instructions. " * 800, budget=DEFAULT_BUDGET)
system = kwargs["system"]

assert system, "system prompt unexpectedly empty"
assert IDENTITY_MARKER in system[0].get("text", "")

def test_budget_zero_disables_trimming(self):
"""Opt-out restores the previous (upstream) behavior."""
text = "Hermes instructions. " * 800
trimmed = _build(text, budget=DEFAULT_BUDGET)
untouched = _build(text, budget=0)

assert _hermes_len(untouched["system"]) > _hermes_len(trimmed["system"])
# Sanitization may rewrite product names, so allow a small delta.
assert _hermes_len(untouched["system"]) >= len(text) - 1

def test_short_system_prompt_is_untouched(self):
"""Prompts below the budget must not be modified at all."""
text = "Be concise."
kwargs = _build(text, budget=DEFAULT_BUDGET)

assert _hermes_len(kwargs["system"]) == len(text)

def test_custom_budget_is_respected(self):
kwargs = _build("Hermes instructions. " * 800, budget=500)

assert _hermes_len(kwargs["system"]) <= 500

def test_non_oauth_requests_are_never_trimmed(self):
"""API-key requests bill per token and must keep the full prompt."""
text = "Hermes instructions. " * 800
kwargs = _build(text, budget=DEFAULT_BUDGET, is_oauth=False)

assert _hermes_len(kwargs["system"]) >= len(text) - 1


class TestOAuthSystemBudgetWireSafety:
"""Trimming must never produce a payload Anthropic rejects."""

def test_no_empty_text_block_on_the_wire(self):
"""Anthropic 400s on empty text blocks — none may survive trimming."""
kwargs = _build("Hermes instructions. " * 800, budget=DEFAULT_BUDGET)

empty = [
b
for b in kwargs["system"]
if isinstance(b, dict) and b.get("type") == "text" and not b.get("text")
]
assert empty == [], "empty text block would trigger HTTP 400"

def test_no_empty_block_retains_cache_control(self):
"""``cache_control`` on an empty text block is always rejected."""
kwargs = _build("Hermes instructions. " * 800, budget=DEFAULT_BUDGET)

offenders = [
b
for b in kwargs["system"]
if isinstance(b, dict)
and b.get("type") == "text"
and not b.get("text")
and b.get("cache_control")
]
assert offenders == [], (
"empty text block with cache_control -> "
"'cache_control cannot be set for empty text blocks'"
)

def test_trim_prefers_a_line_boundary(self):
"""Instructions are cut at a newline, not mid-sentence, when possible."""
# Many short lines guarantee a newline exists just below the budget.
text = "\n".join(f"Instruction line {i}." for i in range(600))
kwargs = _build(text, budget=DEFAULT_BUDGET)

blocks = [
b
for b in kwargs["system"]
if isinstance(b, dict) and b.get("type") == "text" and b.get("text")
]
trimmed_text = blocks[-1]["text"]
assert trimmed_text.endswith("."), "cut landed mid-sentence"
Loading