Skip to content

Commit b3f4436

Browse files
authored
fix(proxy): align signed-thinking wire accounting (#3015)
## Description Signed-thinking histories force byte-faithful passthrough because re-serializing signed Anthropic blocks can invalidate their signatures. Headroom correctly forwarded the original client bytes, but continued reporting mutations, transforms, savings, response headers, and prefix state from a different body that never reached the provider. Separately, the final Anthropic guard hoisted every `role: system` message into the top-level prompt, including valid mid-conversation system sections, changing their semantics and destroying the cached prefix if that mutation ever shipped. This coupled fix makes downstream accounting use the actual wire body whenever the signed-thinking lock discards edits, and narrows system relocation to the current Anthropic model and placement contract. Closes #2990 Closes #2991 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [x] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - Detects signed thinking in the original request as well as the mutated body, so a transform cannot remove the block and accidentally bypass the byte lock. - Keeps the original-body signature probe best-effort under malformed, recursive, and `MemoryError` conditions. - Carries discarded mutation reasons through the streaming forwarder and emits the existing structured warning on HTTP streaming paths too. - When signed passthrough wins, resets message savings, tool-schema savings, attribution ledgers, transform labels, response headers, and prefix tracking to the original client wire body. - Adds bounded public diagnostic tags naming/counting discarded mutation reasons without exposing body content. - Preserves valid mid-conversation system sections on currently supported Claude models and official Anthropic, Bedrock, and parsed `*.googleapis.com` routes; hostname-boundary validation rejects lookalike and userinfo URLs. - Preserves consecutive system sections and enforces documented predecessor/successor placement rules. - Continues relocating initial, invalidly placed, unsupported-model, and conservative third-party-gateway system messages to avoid upstream 400s. - Includes current `main`, including #2996, #2997, #2971, #3009, #3012, and the MCP dependency cap. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text uv run pytest -q <wire/cache/savings/system focused suite> 379 passed uv run pytest -q tests/test_proxy/test_anthropic_recount_and_reparse_safety.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_handler_helpers.py 99 passed pytest tests scripts/tests --splits 4 --group N --tb=short -q All four CI-shaped fresh-process groups passed locally after correcting the MemoryError regression; each completed in roughly 75-83 seconds. Post-CodeQL correction: 91 focused tests passed; all four exact-head CI-shaped shards passed in roughly 82-95 seconds. uv run ruff format --check . 1411 files already formatted uv run ruff check . All checks passed uv run mypy headroom Success: no issues found in 520 source files ``` ## Real Behavior Proof - Environment: macOS arm64, Python 3.13, branch rebased onto current `main`. - Exact command / steps: sent a signed-thinking request whose tool schema is measurably compacted inside the handler, captured the exact upstream bytes, wrapped the real outcome funnel, and inspected response headers, aggregate metrics, attribution tags, transforms, and prefix-tracker state. Exercised valid, consecutive, invalid, initial, supported-model, and unsupported-model system placements. - Observed result: upstream bytes remain byte-identical to the client; discarded edits contribute zero tokens, zero tool savings, no transform header, and no attribution while the prefix tracker stores the actual wire messages. Valid mid-conversation system sections remain in place; only out-of-contract sections relocate. - Not tested: live paid Anthropic traffic with production credentials. The placement/model contract was verified against the current official documentation and wire behavior is covered with a byte-capturing transport. ## Runtime Rollout Safety - Rollout-managed feature(s): signed-thinking wire-truth accounting and Anthropic mid-conversation system preservation. - Minimum rollout channel: normal patch release after exact-head CI is entirely green. - Stable/default behavior changed: discarded mutations no longer inflate savings; supported valid system sections are no longer hoisted into the top-level prompt. - Kill switch / disable path: no unsafe runtime override; human revert restores the previous conservative relocation/accounting behavior. - Unsafe override required: none. - Qualification impact: all Python shards, byte-forwarding, cache-prefix, outcome/savings, signed-thinking, Anthropic handler, static, Docker, and security checks must remain green. - Rollback path: fix forward through a human-reviewed corrective PR; no persisted data or configuration migration is involved. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation — inline wire-contract documentation; no separate guide is required - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) Not applicable; proxy wire behavior and accounting only. ## Additional Notes Human review only. No merge or auto-merge is configured. Current provider contract reference: https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
1 parent 942af56 commit b3f4436

6 files changed

Lines changed: 351 additions & 17 deletions

File tree

headroom/proxy/body_forwarding.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ def has_signed_thinking_blocks(body: dict[str, Any]) -> bool:
8181
return False
8282

8383

84+
def _original_body_has_signed_thinking_blocks(original_body_bytes: bytes | None) -> bool:
85+
if original_body_bytes is None:
86+
return False
87+
try:
88+
original = json.loads(original_body_bytes)
89+
except (json.JSONDecodeError, UnicodeDecodeError, ValueError, MemoryError, RecursionError):
90+
return False
91+
return isinstance(original, dict) and has_signed_thinking_blocks(original)
92+
93+
8494
class BodyMutationTracker:
8595
"""Records whether a request body was mutated and why."""
8696

@@ -123,7 +133,10 @@ def select_outbound_body(
123133
upstream instead of silently claiming the edit landed.
124134
"""
125135
mode = forwarder_mode if forwarder_mode is not None else get_python_forwarder_mode()
126-
if original_body_bytes is not None and has_signed_thinking_blocks(body):
136+
if original_body_bytes is not None and (
137+
has_signed_thinking_blocks(body)
138+
or _original_body_has_signed_thinking_blocks(original_body_bytes)
139+
):
127140
return OutboundBody(
128141
content=original_body_bytes,
129142
source="passthrough",
@@ -180,4 +193,7 @@ def outbound_body_is_client_bytes(
180193
Mirrors the first branch of :func:`select_outbound_body`; the forwarder mode
181194
is deliberately not consulted because that branch overrides it too.
182195
"""
183-
return original_body_bytes is not None and has_signed_thinking_blocks(body)
196+
return original_body_bytes is not None and (
197+
has_signed_thinking_blocks(body)
198+
or _original_body_has_signed_thinking_blocks(original_body_bytes)
199+
)

headroom/proxy/handlers/anthropic.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import uuid
1515
from datetime import datetime
1616
from typing import TYPE_CHECKING, Any
17+
from urllib.parse import urlsplit
1718

1819
from headroom.proxy.stage_timer import StageTimer, emit_stage_timings_log
1920

@@ -50,6 +51,23 @@
5051
logger = logging.getLogger("headroom.proxy")
5152

5253

54+
def _is_googleapis_endpoint(value: object) -> bool:
55+
"""Return whether *value* targets Google APIs by parsed hostname.
56+
57+
A substring check would also trust attacker-controlled hosts such as
58+
``googleapis.com.example.test``. URL parsing plus a label-boundary suffix
59+
check accepts Google API subdomains without widening the route gate.
60+
"""
61+
raw = str(value).strip()
62+
if not raw:
63+
return False
64+
try:
65+
hostname = (urlsplit(raw).hostname or "").rstrip(".").lower()
66+
except ValueError:
67+
return False
68+
return hostname == "googleapis.com" or hostname.endswith(".googleapis.com")
69+
70+
5371
class _AnthropicTurnHookUsage:
5472
"""Usage from hook-triggered Anthropic calls the main response omits.
5573
@@ -3012,7 +3030,19 @@ def _count_tool_tokens(value: object) -> int:
30123030
# the top-level 'system' parameter ..."), so relocate it back to the
30133031
# top-level ``system`` parameter as the last step before forwarding.
30143032
relocated_messages, relocated_system, system_relocated = (
3015-
relocate_system_messages_to_top_level(body["messages"], body.get("system"))
3033+
relocate_system_messages_to_top_level(
3034+
body["messages"],
3035+
body.get("system"),
3036+
(
3037+
str(model)
3038+
if (
3039+
not upstream_base_url
3040+
or getattr(self, "anthropic_backend", None) is not None
3041+
or _is_googleapis_endpoint(upstream_base_url)
3042+
)
3043+
else None
3044+
),
3045+
)
30163046
)
30173047
if system_relocated:
30183048
body["messages"] = relocated_messages
@@ -3393,6 +3423,44 @@ def _count_tool_tokens(value: object) -> int:
33933423
tools = _ttl_tools
33943424
body_mutation_tracker.mark_mutated("cache_control_ttl_order")
33953425

3426+
# Signed thinking locks the request to the client's original
3427+
# bytes. Once all mutation sites have run, make every downstream
3428+
# observer use that same wire body and neutralize savings from
3429+
# edits that will not be sent (#2990). This covers PERF, /stats,
3430+
# durable savings, response headers, pipeline events, and the
3431+
# prefix tracker rather than fixing only one reporting surface.
3432+
if outbound_locked_to_client_bytes and body_mutation_tracker.mutated:
3433+
discarded_reasons = body_mutation_tracker.reasons
3434+
try:
3435+
wire_body = json.loads(original_body_bytes or b"")
3436+
except (json.JSONDecodeError, UnicodeDecodeError, ValueError, RecursionError):
3437+
wire_body = None
3438+
if not isinstance(wire_body, dict):
3439+
raise ValueError(
3440+
"signed-thinking passthrough could not reconstruct its client wire body"
3441+
)
3442+
3443+
from headroom.proxy.savings_attribution import SAVINGS_ATTRIBUTION_TAG
3444+
from headroom.proxy.tool_schema_savings_policy import (
3445+
TOOL_SCHEMA_SAVINGS_TAGS,
3446+
)
3447+
3448+
attribution = tags.get(SAVINGS_ATTRIBUTION_TAG)
3449+
if isinstance(attribution, list):
3450+
attribution.clear()
3451+
for savings_tag in TOOL_SCHEMA_SAVINGS_TAGS:
3452+
tags.pop(savings_tag, None)
3453+
tags.pop("tool_search_deferred_tools", None)
3454+
tags["wire_mutations_discarded"] = len(discarded_reasons)
3455+
tags["wire_mutation_reasons"] = ",".join(discarded_reasons)
3456+
3457+
body = wire_body
3458+
optimized_messages = body.get("messages", [])
3459+
tools = body.get("tools")
3460+
optimized_tokens = original_tokens
3461+
tokens_saved = 0
3462+
transforms_applied = []
3463+
33963464
log_cache_breakpoints(
33973465
request_id=request_id,
33983466
inbound=inbound_breakpoints,

headroom/proxy/handlers/streaming.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,18 +1121,20 @@ async def _stream_response_inner(
11211121
# bytes once before entering the connection-retry loop. When a
11221122
# transform mutated the body we re-serialize canonically; otherwise
11231123
# we forward the original client bytes verbatim.
1124-
from headroom.proxy.body_forwarding import prepare_outbound_body_bytes
1124+
from headroom.proxy.body_forwarding import select_outbound_body
11251125
from headroom.proxy.helpers import (
11261126
capture_codex_wire_debug,
11271127
codex_wire_debug_enabled,
11281128
log_outbound_request,
11291129
)
11301130

1131-
outbound_bytes, outbound_source = prepare_outbound_body_bytes(
1131+
outbound = select_outbound_body(
11321132
body=body,
11331133
original_body_bytes=original_body_bytes,
11341134
body_mutated=body_mutated,
1135+
mutation_reasons=list(mutation_reasons or []),
11351136
)
1137+
outbound_bytes, outbound_source = outbound.content, outbound.source
11361138
outbound_headers = {**headers, "content-type": "application/json"}
11371139
log_outbound_request(
11381140
forwarder="streaming",
@@ -1143,6 +1145,7 @@ async def _stream_response_inner(
11431145
mutation_reasons=list(mutation_reasons or []),
11441146
request_id=request_id,
11451147
source=outbound_source,
1148+
dropped_mutation_reasons=outbound.dropped_mutation_reasons,
11461149
)
11471150
_codex_wire_debug = (
11481151
codex_wire_debug_enabled() and provider == "openai" and "/responses" in url

headroom/proxy/helpers.py

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -905,16 +905,15 @@ def _system_message_to_blocks(message: dict[str, Any]) -> list[Any]:
905905
def relocate_system_messages_to_top_level(
906906
messages: list[dict[str, Any]],
907907
system: Any,
908+
model: str | None = None,
908909
) -> tuple[list[dict[str, Any]], Any, bool]:
909-
"""Move any ``role="system"`` entries out of ``messages`` into ``system``.
910+
"""Relocate only system messages invalid for the selected Anthropic model.
910911
911-
Anthropic's Messages API rejects a ``system`` role inside ``messages`` with
912-
HTTP 400 ("messages.0: use the top-level 'system' parameter for the initial
913-
system prompt"). Internal transforms / pipeline extensions can leave a stray
914-
system message in the list (e.g. a relocated harness system block during
915-
compression). This is the Anthropic forwarder's last line of defense: it
916-
guarantees the forwarded body never violates the wire contract, regardless
917-
of which transform introduced the entry.
912+
Supported models accept mid-conversation system sections after a user turn
913+
(or an assistant server-tool result) when followed by an assistant turn or
914+
placed at the end. Hoisting those changes semantics and invalidates the
915+
cached prefix. The initial/invalid forms are still moved to the top-level
916+
field as the issue-765 last-line wire-contract guard.
918917
919918
The relocated content is appended after any existing top-level ``system``
920919
so wire order (system prompt, then conversation) is preserved and no content
@@ -924,9 +923,58 @@ def relocate_system_messages_to_top_level(
924923
message is present the inputs pass through unchanged (``changed=False``) so
925924
the common path is untouched.
926925
"""
927-
system_indices = {
928-
i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == _ROLE_SYSTEM
929-
}
926+
model_id = str(model or "").lower()
927+
supports_mid_conversation = any(
928+
family in model_id
929+
for family in (
930+
"claude-fable-5",
931+
"claude-mythos-5",
932+
"claude-opus-4-8",
933+
"claude-opus-5",
934+
"claude-sonnet-5",
935+
)
936+
)
937+
938+
def _assistant_ends_in_server_tool_result(message: object) -> bool:
939+
if not isinstance(message, dict) or message.get("role") != "assistant":
940+
return False
941+
content = message.get("content")
942+
if not isinstance(content, list) or not content:
943+
return False
944+
final = content[-1]
945+
if not isinstance(final, dict):
946+
return False
947+
block_type = str(final.get("type") or "")
948+
return block_type == "server_tool_use" or block_type.endswith("_tool_result")
949+
950+
system_indices: set[int] = set()
951+
index = 0
952+
while index < len(messages):
953+
message = messages[index]
954+
if not isinstance(message, dict) or message.get("role") != _ROLE_SYSTEM:
955+
index += 1
956+
continue
957+
958+
section_start = index
959+
while (
960+
index + 1 < len(messages)
961+
and isinstance(messages[index + 1], dict)
962+
and messages[index + 1].get("role") == _ROLE_SYSTEM
963+
):
964+
index += 1
965+
section_end = index
966+
967+
previous = messages[section_start - 1] if section_start > 0 else None
968+
following = messages[section_end + 1] if section_end + 1 < len(messages) else None
969+
valid_previous = (
970+
isinstance(previous, dict) and previous.get("role") == "user"
971+
) or _assistant_ends_in_server_tool_result(previous)
972+
valid_following = following is None or (
973+
isinstance(following, dict) and following.get("role") == "assistant"
974+
)
975+
if not (supports_mid_conversation and valid_previous and valid_following):
976+
system_indices.update(range(section_start, section_end + 1))
977+
index += 1
930978
if not system_indices:
931979
return messages, system, False
932980

tests/test_proxy_byte_faithful_forwarding.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,31 @@ def test_signed_thinking_passthrough_reports_the_mutations_it_discarded() -> Non
282282
assert outbound.dropped_mutation_reasons == ("ccr_streaming_retrieve_buffered_non_stream",)
283283

284284

285+
def test_original_signed_thinking_still_locks_when_mutation_removed_the_block() -> None:
286+
original_body = {
287+
"messages": [
288+
{
289+
"role": "assistant",
290+
"content": [{"type": "thinking", "signature": "sig123"}],
291+
}
292+
]
293+
}
294+
mutated_body = {"messages": [{"role": "assistant", "content": "rewritten"}]}
295+
original = json.dumps(original_body, indent=2).encode()
296+
297+
outbound = select_outbound_body(
298+
body=mutated_body,
299+
original_body_bytes=original,
300+
body_mutated=True,
301+
forwarder_mode="byte_faithful",
302+
mutation_reasons=["compression"],
303+
)
304+
305+
assert outbound.content == original
306+
assert outbound.source == "passthrough"
307+
assert outbound.dropped_mutation_reasons == ("compression",)
308+
309+
285310
def test_signed_thinking_passthrough_reports_nothing_when_body_unmutated() -> None:
286311
body = {
287312
"messages": [
@@ -566,6 +591,88 @@ def _make_no_optimize_app() -> tuple[TestClient, _CapturingTransport]:
566591
return _make_anthropic_app(optimize=False)
567592

568593

594+
def test_signed_thinking_discarded_mutation_uses_wire_truth_for_all_accounting() -> None:
595+
config = ProxyConfig(
596+
optimize=False,
597+
cache_enabled=False,
598+
rate_limit_enabled=False,
599+
cost_tracking_enabled=False,
600+
log_requests=False,
601+
ccr_inject_tool=False,
602+
ccr_handle_responses=False,
603+
ccr_context_tracking=False,
604+
image_optimize=False,
605+
)
606+
app = create_app(config)
607+
proxy = app.state.proxy
608+
transport = _CapturingTransport()
609+
proxy.http_client = httpx.AsyncClient(transport=transport)
610+
proxy._record_request_outcome = AsyncMock(wraps=proxy._record_request_outcome)
611+
612+
tracker = _FakePrefixTracker(frozen_count=0)
613+
proxy.session_tracker_store.compute_session_id = lambda request, model, messages: "signed"
614+
proxy.session_tracker_store.get_or_create = lambda session_id, provider: tracker
615+
616+
inbound = {
617+
"model": "claude-opus-5",
618+
"max_tokens": 64,
619+
"messages": [
620+
{"role": "user", "content": "Solve this."},
621+
{
622+
"role": "assistant",
623+
"content": [
624+
{
625+
"type": "thinking",
626+
"thinking": "private",
627+
"signature": "sig123",
628+
},
629+
{"type": "text", "text": "Working."},
630+
],
631+
},
632+
{"role": "user", "content": "Continue."},
633+
],
634+
"tools": [
635+
{
636+
"name": "lookup",
637+
"description": " Look up a value. ",
638+
"input_schema": {
639+
"$schema": "https://json-schema.org/draft/2020-12/schema",
640+
"type": "object",
641+
"properties": {"key": {"type": "string"}},
642+
},
643+
}
644+
],
645+
}
646+
inbound_bytes = json.dumps(inbound, indent=2).encode()
647+
648+
response = TestClient(app).post(
649+
"/v1/messages",
650+
headers={
651+
"x-api-key": "test-key",
652+
"anthropic-version": "2023-06-01",
653+
"content-type": "application/json",
654+
},
655+
content=inbound_bytes,
656+
)
657+
658+
assert response.status_code == 200
659+
assert transport.captured_body == inbound_bytes
660+
assert response.headers["x-headroom-tokens-saved"] == "0"
661+
assert "x-headroom-transforms" not in response.headers
662+
663+
outcome = proxy._record_request_outcome.await_args.args[0]
664+
assert outcome.tokens_saved == 0
665+
assert outcome.optimized_tokens == outcome.original_tokens
666+
assert outcome.transforms_applied == ()
667+
assert outcome.tags["wire_mutations_discarded"] > 0
668+
assert "anthropic:tool_schema_compaction" not in outcome.transforms_applied
669+
assert "tool_search_deferred_tokens" not in outcome.tags
670+
assert outcome.tags.get("_headroom_savings_attribution") == []
671+
assert proxy.metrics.tokens_saved_total == 0
672+
assert proxy.metrics.tool_search_saved_total == 0
673+
assert tracker._last_forwarded_messages[: len(inbound["messages"])] == inbound["messages"]
674+
675+
569676
def _openai_responses_body_bytes(*, stream: bool) -> bytes:
570677
payload = {
571678
"model": "gpt-5.5",

0 commit comments

Comments
 (0)