diff --git a/headroom/proxy/body_forwarding.py b/headroom/proxy/body_forwarding.py index c02a4e8df..8669c316d 100644 --- a/headroom/proxy/body_forwarding.py +++ b/headroom/proxy/body_forwarding.py @@ -56,8 +56,30 @@ def get_python_forwarder_mode() -> PythonForwarderMode: def serialize_body_canonical(body: dict[str, Any]) -> bytes: - """Re-serialize a request body deterministically with cache-stable formatting.""" - return json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + """Re-serialize a request body deterministically with cache-stable formatting. + + ``ensure_ascii=False`` keeps the bytes compact and cache-stable, but it also + means a lone surrogate anywhere in the body raises here. That is reachable + input, not a hypothetical: ``"\\ud800"`` is valid JSON, ``json.loads`` + accepts it happily, and a tool result carrying truncated UTF-16 or sliced + binary produces one. Both forwarders resolve outbound bytes *outside* their + connection-retry loop, so the exception escapes as a 500 with no retry. + + #3124 made that newly load-bearing: mutated thinking-bearing bodies used to + return the client's bytes verbatim and never reached this function at all, + so the largest, most tool-result-heavy population in Claude Code traffic now + depends on it not raising. + + The escaped form is the right degradation -- it encodes the identical parsed + values, so upstream reconstructs exactly the same request, and every mutation + still reaches the wire (important: the caller's ``stream`` flip rides on + these bytes). Only the byte-level encoding differs, costing one cache miss on + a request that would otherwise have failed outright. + """ + try: + return json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + except UnicodeEncodeError: + return json.dumps(body, separators=(",", ":"), ensure_ascii=True).encode("utf-8") def has_signed_thinking_blocks(body: dict[str, Any]) -> bool: diff --git a/tests/test_proxy_byte_faithful_forwarding.py b/tests/test_proxy_byte_faithful_forwarding.py index f96173e47..9901282dc 100644 --- a/tests/test_proxy_byte_faithful_forwarding.py +++ b/tests/test_proxy_byte_faithful_forwarding.py @@ -1893,3 +1893,52 @@ def test_unparseable_original_cannot_prove_preservation( monkeypatch.setenv("HEADROOM_THINKING_PRESERVING_MUTATIONS", "1") assert thinking_blocks_survived_mutation(_tb_body(), b"{not json") is False assert thinking_blocks_survived_mutation(_tb_body(), None) is False + + +def test_lone_surrogate_in_thinking_body_serializes_instead_of_raising(): + """A lone surrogate must not turn a mutated thinking body into a 500. + + ``"\\ud800"`` is valid JSON, so ``json.loads`` accepts it and a tool result + carrying truncated UTF-16 produces one. Before #3124 a mutated + thinking-bearing body returned the client's bytes verbatim and never reached + canonical serialization; now it does, and both forwarders resolve outbound + bytes outside their retry loop, so a raise here escapes as an unretried 500. + """ + import json + + from headroom.proxy.body_forwarding import select_outbound_body + + lone_surrogate = chr(0xD800) + original = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": f"reasoning {lone_surrogate}", + "signature": "sig", + } + ], + } + ] + } + original_bytes = json.dumps(original, ensure_ascii=True).encode("utf-8") + mutated = json.loads(original_bytes) + mutated["messages"].append({"role": "user", "content": "compressed"}) + + outbound = select_outbound_body( + body=mutated, + original_body_bytes=original_bytes, + body_mutated=True, + forwarder_mode="byte_faithful", + ) + + # The relaxation still applies (the thinking block is untouched) and the + # mutation reaches the wire rather than being discarded or crashing. + assert outbound.source == "canonical" + assert not outbound.dropped_mutations + reparsed = json.loads(outbound.content) + assert reparsed == mutated + # The signed block round-trips to exactly the values the client sent. + assert reparsed["messages"][0] == original["messages"][0]