mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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)
184 lines
6.3 KiB
Python
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
|