headroom/tests/test_sse_thinking_blocks.py
chopratejas 148ded392a fix: A8 — SSE delta arms, UTF-8 buffer, phase preservation, request-id, 413
Eliminates the Python wire-format hotfix bugs gated on Phase A's
lockdown so the proxy is safe through Phase H's Python retirement.

Bugs retired:
  - P0-7 / P4-44: Codex `phase` field is now explicitly preserved
    through the Responses-API ↔ Chat-Completions round-trip; multi
    text-part rebuild collapses to a single text part (no more
    content doubling).
  - P1-8: Bytes-level SSE event splitter
    `parse_sse_events_from_byte_buffer`; emoji/CJK split across
    chunks survive intact. Buffer is `bytearray`; UTF-8 decode happens
    only AFTER the `\n\n` event terminator is located in bytes.
    Invalid UTF-8 in a *complete* event raises (operator-visible
    diagnostic, not silent corruption).
  - P1-9: `_parse_sse_to_response` handles all delta types per
    Anthropic guide §5.1: `thinking_delta`, `signature_delta`,
    `citations_delta`. Block map keyed by `index` so out-of-order
    events reconstruct correctly. `redacted_thinking.data` preserved.
  - P4-47: Unknown Responses-API item types now log a structured
    `unknown_responses_item_type` warning so operators see new
    Codex item types in flight before they break.
  - P5-57: Rust proxy captures upstream `request-id` (Anthropic) and
    `x-request-id` (OpenAI); surfaced as `headroom-upstream-request-id`
    on the response and as a tracing span field. Distinct from the
    proxy's own `x-request-id`.
  - P5-59: Body-too-large now returns 413 (was 400). Pre-checks
    `Content-Length` and rejects without consuming the body when
    present; chunked uploads still buffer-then-fail with 413.

Configurability (no hardcodes):
  - HEADROOM_SSE_BUFFER_MAX_BYTES (default 1 MiB) — per-event cap.
  - HEADROOM_PROXY_BODY_TOO_LARGE_STATUS (default 413) — operator
    override for body-too-large status.

A7 follow-up: `_DummyAnthropicHandler._retry_request` accepts the
A3 byte-faithful kwargs (`original_body_bytes`, `body_mutated`,
`mutation_reasons`, `request_id`, `forwarder_name`, `path_for_log`)
so the existing 20 backpressure tests stay green against the real
handler signature.

The project-wide grep
  git grep 'errors="ignore"\|errors="replace"' headroom/proxy/handlers/ headroom/ccr/
returns nothing; the single remaining lossy-decode site (response-
body diagnostics, not SSE) routes through `safe_decode_for_logging`
in `headroom/proxy/helpers.py`.

Tests:
  - tests/test_sse_thinking_blocks.py (4 tests)
  - tests/test_sse_utf8_split.py (3 tests)
  - tests/test_proxy_responses_phase_preservation.py (4 tests)
  - crates/headroom-proxy/tests/integration_request_id.rs (2 tests)
  - crates/headroom-proxy/tests/integration_body_size.rs (2 tests)
2026-05-02 10:35:11 -07:00

184 lines
6.3 KiB
Python

"""PR-A8 / P1-9: SSE delta arms for thinking, signature, citations.
The proxy used to handle only ``text_delta`` and ``input_json_delta``
events on Anthropic's stream. The remaining delta types
(``thinking_delta``, ``signature_delta``, ``citations_delta``) and the
``redacted_thinking`` content_block_start were silently dropped, so any
non-streaming retry path that reconstructed the response from the SSE
stream produced an unsigned thinking block (rejected by Anthropic on
replay) or empty citations.
These tests pin the new contract:
- ``thinking_delta`` text appends to ``block.thinking_buffer`` and is
promoted to ``block.thinking`` on ``content_block_stop``.
- ``signature_delta`` sets ``block.signature`` (last-write-wins).
- ``citations_delta`` appends each citation object to ``block.citations``.
- ``redacted_thinking`` content_block_start preserves the opaque
``data`` field as-is.
"""
from __future__ import annotations
import json
from typing import Any
from headroom.proxy.handlers.streaming import StreamingMixin
class _Parser(StreamingMixin):
"""Subclass that exposes the parser without the rest of the proxy."""
def _build_sse(events: list[dict[str, Any]]) -> str:
"""Render a list of event dicts as an SSE payload string."""
out: list[str] = []
for ev in events:
out.append(f"event: {ev['type']}")
out.append(f"data: {json.dumps(ev)}")
out.append("") # event terminator
return "\n".join(out) + "\n"
def test_thinking_delta_accumulated() -> None:
parser = _Parser()
events = [
{"type": "message_start", "message": {"id": "msg_1", "model": "claude-opus-4"}},
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": ""},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "Let me consider "},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "the question carefully."},
},
{"type": "content_block_stop", "index": 0},
]
sse = _build_sse(events)
response = parser._parse_sse_to_response(sse, "anthropic")
assert response is not None
assert len(response["content"]) == 1
block = response["content"][0]
assert block["type"] == "thinking"
assert block["thinking"] == "Let me consider the question carefully."
def test_signature_delta_preserved() -> None:
parser = _Parser()
events = [
{"type": "message_start", "message": {"id": "msg_1", "model": "claude-opus-4"}},
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": ""},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "hmm"},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "signature_delta", "signature": "sig_abc123_v1"},
},
{"type": "content_block_stop", "index": 0},
]
sse = _build_sse(events)
response = parser._parse_sse_to_response(sse, "anthropic")
assert response is not None
block = response["content"][0]
assert block["signature"] == "sig_abc123_v1"
# Last-write-wins semantics — second signature_delta overrides.
events2 = events + [
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "signature_delta", "signature": "sig_xyz999_v2"},
},
]
# Re-emit with the corrected ordering: stop must come after all deltas.
events2 = [e for e in events2 if e["type"] != "content_block_stop"]
events2.append({"type": "content_block_stop", "index": 0})
response2 = parser._parse_sse_to_response(_build_sse(events2), "anthropic")
assert response2 is not None
assert response2["content"][0]["signature"] == "sig_xyz999_v2"
def test_citations_delta_accumulated() -> None:
parser = _Parser()
events = [
{"type": "message_start", "message": {"id": "msg_1", "model": "claude-opus-4"}},
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Per source A"},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "citations_delta",
"citation": {
"type": "page_location",
"cited_text": "abc",
"document_index": 0,
},
},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "citations_delta",
"citation": {
"type": "page_location",
"cited_text": "def",
"document_index": 1,
},
},
},
{"type": "content_block_stop", "index": 0},
]
sse = _build_sse(events)
response = parser._parse_sse_to_response(sse, "anthropic")
assert response is not None
block = response["content"][0]
citations = block["citations"]
assert len(citations) == 2
assert citations[0]["cited_text"] == "abc"
assert citations[1]["cited_text"] == "def"
def test_redacted_thinking_data_preserved() -> None:
parser = _Parser()
redacted_blob = "ENC:" + ("x" * 200)
events = [
{"type": "message_start", "message": {"id": "msg_1", "model": "claude-opus-4"}},
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "redacted_thinking", "data": redacted_blob},
},
{"type": "content_block_stop", "index": 0},
]
sse = _build_sse(events)
response = parser._parse_sse_to_response(sse, "anthropic")
assert response is not None
block = response["content"][0]
assert block["type"] == "redacted_thinking"
# `data` field MUST be preserved byte-for-byte for signature
# validation on the next turn.
assert block["data"] == redacted_blob