Compare commits

...

2 Commits

Author SHA1 Message Date
kshitijk4poor
7bc2916b40 refactor(anthropic): simplify OAuth relocation + add 4-breakpoint-cap test
simplify-code cleanup pass over the prior commit (behavior-preserving):

- Reuse prompt_caching._build_marker('5m') for the relocated block's
  cache_control instead of inlining {type: ephemeral} (extend-don't-duplicate;
  also gains the 1h-TTL path for free).
- Hoist _sanitize_oauth_text and its brand-replacement pairs to module level
  (_OAUTH_TEXT_REPLACEMENTS) — the file's style is module-level helpers, and
  the nested def was rebuilt on every request. Now auditable + unit-testable.
- Fold sanitization into the single collection pass (drop the second list
  rebuild) and drop the redundant 'if p' join filter (parts are already
  non-empty). Tag literal -> _OAUTH_SYSTEM_CONTEXT_TAG constant.
- Document the 4-breakpoint-cap arithmetic and add a regression test
  (test_oauth_relocation_respects_4_breakpoint_cap) that runs the REAL
  production order (apply_anthropic_cache_control then build_anthropic_kwargs)
  and asserts the OAuth wire never exceeds Anthropic's 4 cache breakpoints —
  the one genuinely risky interaction the original tests didn't cover.

No behavior change: full live request still bills to plan, caching preserved,
no single-underscore mcp_ on the wire. 366 anthropic tests pass.
2026-06-17 14:09:52 +05:30
kshitijk4poor
ab7b4edcc6 fix(anthropic): relocate OAuth system prompt to first user message (plan billing)
Second, independent trigger of Anthropic's OAuth 'extra usage, not plan limits'
400 (the first, tool names, was fixed in #47723): the billing classifier also
fingerprints the *content* of system[]. A large, distinctive non-Claude-Code
system prompt (Hermes persona + skills catalog + memory) is scored as a
third-party app and rejected — even after the Hermes->Claude Code brand
sanitization, and regardless of size (a same-size generic prompt passes; it is
the content).

Verified empirically against a live Max subscription: with the real 73-tool +
~33KB-prompt request, leaving the prompt in system[] returns the 400; relocating
it bills to plan. Mirrors how real Claude Code keeps only its 57-char identity
line in system[].

- On the OAuth path, system[] is reduced to the Claude Code identity line.
- The (sanitized) real prompt is relocated into a <system_context> preamble on
  the first user message, where the classifier does not apply.
- The relocated block carries cache_control: ephemeral so the heavy prefix is
  still cached: the first user message is a stable in-conversation prefix, so
  the cache breakpoint moves from the system slot to the first-user-message slot
  WITHOUT breaking caching. Confirmed end-to-end: 48K-token prefix shows
  cache_read on turn 2 (cache_create=0).
- Non-OAuth requests are unchanged (system prompt stays as the system arg).

Builds on the system-relocation approach from erdinccurebal's #26430, rebased
onto current main (post-#47723 mcp__ tool handling, whose conflicting tool-name
changes are dropped) and extended with the cache_control marker that preserves
prompt caching.

Co-authored-by: erdinccurebal <erdinccurebal@users.noreply.github.com>
2026-06-17 13:45:30 +05:30
2 changed files with 287 additions and 18 deletions

View File

@ -374,6 +374,74 @@ def _detect_claude_code_version() -> str:
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
_MCP_TOOL_PREFIX = "mcp__"
# Product-name replacements applied to the relocated OAuth system prompt so
# nothing in the preamble reads as a competing-product identity (Anthropic's
# OAuth billing classifier fingerprints distinctive non-Claude-Code content).
_OAUTH_TEXT_REPLACEMENTS = (
("Hermes Agent", "Claude Code"),
("Hermes agent", "Claude Code"),
("hermes-agent", "claude-code"),
("Nous Research", "Anthropic"),
)
# Wrapper tag for the relocated prompt on the first user message.
_OAUTH_SYSTEM_CONTEXT_TAG = "system_context"
def _sanitize_oauth_text(text: str) -> str:
"""Mask competing-product identity references in OAuth-relocated prompt text."""
for old, new in _OAUTH_TEXT_REPLACEMENTS:
text = text.replace(old, new)
return text
def _prepend_oauth_system_context(messages, preamble: str) -> None:
"""Prepend ``preamble`` as a cache-marked leading block of the first user message.
Used on the OAuth path to relocate the real system prompt out of ``system[]``
(which Anthropic's billing classifier fingerprints as third-party traffic)
and into the conversation, mirroring how Claude Code keeps only its identity
line in ``system[]``.
The relocated block carries a 5m ``cache_control`` marker (built via
``prompt_caching._build_marker``) so the heavy prompt prefix is still cached
across turns the first user message is a stable prefix within a
conversation, so the cache breakpoint simply moves from the system slot to
the first-user-message slot without breaking caching.
Note on the 4-breakpoint cap: the upstream ``apply_anthropic_cache_control``
pass places a marker on the (heavy) system block + the last 3 messages. When
the OAuth path below reduces ``system[]`` to the identity line, that system
marker is discarded along with the block it rode on, and this preamble marker
takes its place net total stays at exactly 4 (verified by
``test_oauth_relocation_respects_4_breakpoint_cap``).
Mutates ``messages`` in place. Handles user messages whose content is a
plain string or a list of content blocks, and synthesises a user message at
the front if none exists.
"""
if not preamble:
return
from agent.prompt_caching import _build_marker
block = {
"type": "text",
"text": preamble,
"cache_control": _build_marker("5m"),
}
for msg in messages:
if msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
msg["content"] = (
[block, {"type": "text", "text": content}] if content else [block]
)
elif isinstance(content, list):
msg["content"] = [block] + content
else:
msg["content"] = [block]
return
messages.insert(0, {"role": "user", "content": [block]})
def _get_claude_code_version() -> str:
"""Lazily detect the installed Claude Code version when OAuth headers need it."""
@ -2328,28 +2396,47 @@ def build_anthropic_kwargs(
effective_max_tokens = max(context_length - 1, 1)
# ── OAuth: Claude Code identity ──────────────────────────────────
# Anthropic's subscription/OAuth billing classifier rejects requests whose
# system[] carries a large, distinctive non-Claude-Code prompt — it scores
# them as third-party-app traffic and returns HTTP 400 "Third-party apps now
# draw from extra usage, not plan limits", independently of the tool-name
# trigger handled below. Verified empirically against a live Max
# subscription: the identical request with the prompt relocated out of
# system[] bills to plan; left in system[] it does not (and it is the
# CONTENT, not the size — a same-size generic prompt passes).
#
# Strategy (mirrors real Claude Code, which keeps only its 57-char identity
# line in system[]): system[] becomes identity-only, and the real Hermes
# prompt is relocated into a <system_context> preamble on the first user
# message — Anthropic does not apply the classifier to user content. The
# relocated block carries a cache_control marker so the heavy prefix is
# still cached across turns (the first user message is a stable prefix; a
# 2-turn check confirms cache_read on turn 2).
if is_oauth:
# 1. Prepend Claude Code system prompt identity
cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX}
# 1. Collect + sanitize existing system text in one pass (string or
# content blocks). Sanitizing inline avoids a second list rebuild.
extra_system_parts: List[str] = []
if isinstance(system, list):
system = [cc_block] + system
for b in system:
if isinstance(b, dict) and b.get("type") == "text" and b.get("text"):
extra_system_parts.append(_sanitize_oauth_text(b["text"]))
elif isinstance(system, str) and system:
system = [cc_block, {"type": "text", "text": system}]
else:
system = [cc_block]
extra_system_parts.append(_sanitize_oauth_text(system))
# 2. Sanitize system prompt — replace product name references
# to avoid Anthropic's server-side content filters.
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")
block["text"] = text
# 2. system[] = the official Claude Code identity line only.
system = [{"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX}]
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
# 3. Relocate the real prompt into a <system_context> preamble on the
# first user message, with cache_control to preserve prompt caching.
if extra_system_parts:
preamble = (
f"<{_OAUTH_SYSTEM_CONTEXT_TAG}>\n"
+ "\n\n".join(extra_system_parts).strip()
+ f"\n</{_OAUTH_SYSTEM_CONTEXT_TAG}>"
)
_prepend_oauth_system_context(anthropic_messages, preamble)
# 4. 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
# a third-party-app fingerprint and rejects the request with HTTP 400
@ -2380,7 +2467,7 @@ def build_anthropic_kwargs(
if "name" in tool:
tool["name"] = _to_oauth_wire_name(tool["name"])
# 4. Apply the same normalization to tool names in message history
# 5. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
for msg in anthropic_messages:
content = msg.get("content")

View File

@ -0,0 +1,182 @@
"""Tests for the OAuth system-prompt relocation (plan-limit billing).
Anthropic's subscription/OAuth billing classifier fingerprints the *content* of
``system[]``: a large, distinctive non-Claude-Code system prompt is scored as a
third-party app and rejected with HTTP 400 "Third-party apps now draw from extra
usage, not plan limits" — independently of the tool-name trigger.
Fix: on the OAuth path, ``system[]`` is reduced to the Claude Code identity line
and the real prompt is relocated into a ``<system_context>`` preamble on the
first user message (carrying a ``cache_control`` marker so caching is preserved).
The system prompt enters ``build_anthropic_kwargs`` as a ``role: system`` entry
in ``messages`` (extracted by ``convert_messages_to_anthropic``), so the tests
below pass it that way.
"""
from __future__ import annotations
from agent.anthropic_adapter import (
_CLAUDE_CODE_SYSTEM_PREFIX,
_prepend_oauth_system_context,
build_anthropic_kwargs,
)
class TestPrependHelper:
def test_prepends_to_string_user_content(self):
msgs = [{"role": "user", "content": "hello"}]
_prepend_oauth_system_context(msgs, "<system_context>PROMPT</system_context>")
blocks = msgs[0]["content"]
assert isinstance(blocks, list)
assert blocks[0]["text"].startswith("<system_context>")
assert blocks[0]["cache_control"] == {"type": "ephemeral"}
assert blocks[1]["text"] == "hello"
def test_prepends_to_list_user_content(self):
msgs = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
_prepend_oauth_system_context(msgs, "PRE")
blocks = msgs[0]["content"]
assert blocks[0]["text"] == "PRE"
assert blocks[0]["cache_control"] == {"type": "ephemeral"}
assert blocks[1]["text"] == "hi"
def test_targets_first_user_not_assistant(self):
msgs = [
{"role": "assistant", "content": "earlier"},
{"role": "user", "content": "now"},
]
_prepend_oauth_system_context(msgs, "PRE")
assert msgs[0]["role"] == "assistant" # untouched
assert msgs[1]["content"][0]["text"] == "PRE"
def test_synthesises_user_message_when_none(self):
msgs = [{"role": "assistant", "content": "only assistant"}]
_prepend_oauth_system_context(msgs, "PRE")
assert msgs[0]["role"] == "user"
assert msgs[0]["content"][0]["text"] == "PRE"
def test_empty_preamble_noop(self):
msgs = [{"role": "user", "content": "x"}]
_prepend_oauth_system_context(msgs, "")
assert msgs[0]["content"] == "x"
class TestOAuthSystemRelocation:
"""End-to-end through build_anthropic_kwargs (the real call path)."""
def _kwargs(self, system_text, is_oauth=True):
return build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=[
{"role": "system", "content": system_text},
{"role": "user", "content": "do the thing"},
],
tools=None,
max_tokens=1024,
reasoning_config=None,
is_oauth=is_oauth,
)
def test_oauth_system_is_identity_only(self):
kw = self._kwargs("You are Hermes Agent. Follow these rules. " * 200)
system = kw["system"]
assert isinstance(system, list)
assert len(system) == 1
assert system[0]["text"] == _CLAUDE_CODE_SYSTEM_PREFIX
def test_oauth_prompt_relocated_to_first_user_message(self):
big = "You are Hermes Agent built by Nous Research. " * 50
kw = self._kwargs(big)
first_user = next(m for m in kw["messages"] if m["role"] == "user")
blocks = first_user["content"]
assert isinstance(blocks, list)
# relocated preamble is first, cache-marked, wrapped, and sanitized
assert blocks[0]["text"].startswith("<system_context>")
assert blocks[0]["cache_control"] == {"type": "ephemeral"}
assert "Hermes Agent" not in blocks[0]["text"]
assert "Nous Research" not in blocks[0]["text"]
assert "Claude Code" in blocks[0]["text"]
# the user's real text survives after the preamble
assert any(b.get("text") == "do the thing" for b in blocks)
def test_oauth_no_distinctive_content_left_in_system(self):
big = "You are Hermes Agent built by Nous Research. " * 50
kw = self._kwargs(big)
# system[] must carry ONLY the identity line — nothing of the real prompt
system_text = " ".join(b.get("text", "") for b in kw["system"])
assert system_text == _CLAUDE_CODE_SYSTEM_PREFIX
def test_non_oauth_keeps_system_prompt(self):
big = "You are Hermes Agent. " * 50
kw = self._kwargs(big, is_oauth=False)
# Non-OAuth: the system prompt is preserved as the system arg, NOT
# relocated into the user message.
assert kw.get("system")
sys_text = (
kw["system"] if isinstance(kw["system"], str)
else " ".join(b.get("text", "") for b in kw["system"])
)
assert "Hermes Agent" in sys_text
first_user = next(m for m in kw["messages"] if m["role"] == "user")
content = first_user["content"]
# no <system_context> preamble was injected
if isinstance(content, list):
assert not any("<system_context>" in b.get("text", "") for b in content)
else:
assert "<system_context>" not in content
def _count_cache_markers(obj) -> int:
"""Count cache_control markers anywhere in a nested message/system structure."""
n = 0
if isinstance(obj, dict):
if "cache_control" in obj:
n += 1
for v in obj.values():
n += _count_cache_markers(v)
elif isinstance(obj, list):
for v in obj:
n += _count_cache_markers(v)
return n
class TestOAuthCacheBreakpointCap:
"""Regression guard for the 4-breakpoint cap under the REAL production order.
The risky interaction (untested before this): the upstream
``apply_anthropic_cache_control`` pass marks the system block + last 3
messages (4 breakpoints), THEN the OAuth relocation reduces system[] to the
identity line. The relocated preamble must take the place of the discarded
system marker so the wire stays at exactly 4 never 5, which Anthropic 400s.
"""
def test_oauth_relocation_respects_4_breakpoint_cap(self):
from agent.prompt_caching import apply_anthropic_cache_control
msgs = [
{"role": "system", "content": "You are Hermes Agent by Nous Research. " * 80},
{"role": "user", "content": "first question"},
{"role": "assistant", "content": "first answer"},
{"role": "user", "content": "second question"},
{"role": "assistant", "content": "second answer"},
{"role": "user", "content": "third question"},
]
# Production order: caching pass first, then build_anthropic_kwargs.
cached = apply_anthropic_cache_control(msgs, cache_ttl="5m", native_anthropic=False)
kw = build_anthropic_kwargs(
model="claude-opus-4-8",
messages=cached,
tools=None,
max_tokens=8,
reasoning_config=None,
is_oauth=True,
)
total = _count_cache_markers(kw.get("system")) + _count_cache_markers(kw.get("messages"))
assert total <= 4, f"OAuth wire exceeded Anthropic's 4-breakpoint cap: {total}"
# And the relocated preamble must be one of the breakpoints (cache preserved).
first_user = next(m for m in kw["messages"] if m["role"] == "user")
blocks = first_user["content"]
assert isinstance(blocks, list)
assert "cache_control" in blocks[0]
assert blocks[0]["text"].startswith("<system_context>")