mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description Fixes Anthropic-compatible streaming requests that can emit the internal `headroom_retrieve` CCR tool. When a `stream: true` request includes the CCR retrieve tool and response handling is enabled, Headroom now buffers the upstream call as `stream: false`, lets the existing CCR response handler retrieve and continue, and returns the final result as Anthropic SSE so streaming clients do not see the internal tool call. Closes #1450 ## 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 - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Detect direct Anthropic-compatible `stream: true` requests where `headroom_retrieve` is available and CCR response handling is enabled. - Route those requests through the existing buffered/non-stream CCR response handler, then convert the final response back to `text/event-stream`. - Fail closed with a 502 SSE error if a buffered response still contains `headroom_retrieve` after CCR handling, instead of leaking the internal tool to the client. - Preserve Anthropic `thinking`, `redacted_thinking`, signatures, and citations when converting response JSON back to SSE. - Add regression coverage for handled CCR retrieval, unused CCR tool availability, normal streaming passthrough, mixed client/CCR tool fail-closed behavior, and SSE conversion preservation. ## 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 $ rtk gh pr checks 1451 --repo headroomlabs-ai/headroom CI Checks Summary: [ok] Passed: 20 [FAIL] Failed: 0 Relevant CI commands from .github/workflows/ci.yml: - ruff check . - ruff format --check . - mypy headroom --ignore-missing-imports - pytest tests scripts/tests $ rtk python3 -m py_compile headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/streaming.py tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_sse_thinking_blocks.py # passed, no output $ rtk pytest tests/test_sse_thinking_blocks.py -q Pytest: 6 passed $ MACOSX_DEPLOYMENT_TARGET=15.0 rtk uv run --python 3.13 pytest tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py -q Failed before test collection while building the local editable package: esaxx-rs build failed with fatal error: 'cstdint' file not found. ``` ## Real Behavior Proof - Environment: GitHub Actions CI on PR #1451 plus local macOS worktree `fix/1450-ccr-streaming-retrieve`. - Exact command / steps: CI ran lint, type checking, build, unit-test shards, dashboard tests, extras tests, and e2e jobs; locally ran syntax checks and the SSE conversion regression tests. - Observed result: CI passed 20 checks with 0 failures; local syntax checks passed; `tests/test_sse_thinking_blocks.py` passed with 6 tests. - Not tested: the new proxy-level regression test was not run locally because the local native extension build fails in `esaxx-rs` before proxy tests can collect; it is included in the CI-tested suite. ## 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 - [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 have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes - Scope: this handles the direct Anthropic-compatible HTTP `/v1/messages` path. The configured Bedrock/backend streaming path does not share this CCR continuation machinery in this PR. - Documentation, CHANGELOG, code-comment, and local-full-test checklist items are N/A for this narrow bug fix or not true locally.
238 lines
8.1 KiB
Python
238 lines
8.1 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
|
|
|
|
import pytest
|
|
|
|
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
|
|
|
|
|
|
def test_response_to_sse_preserves_thinking_redacted_and_citations() -> None:
|
|
parser = _Parser()
|
|
redacted_blob = "ENC:" + ("y" * 200)
|
|
response = {
|
|
"id": "msg_2",
|
|
"model": "claude-opus-4",
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "plan carefully", "signature": "sig_123"},
|
|
{
|
|
"type": "text",
|
|
"text": "Per source A",
|
|
"citations": [
|
|
{
|
|
"type": "page_location",
|
|
"cited_text": "abc",
|
|
"document_index": 0,
|
|
}
|
|
],
|
|
},
|
|
{"type": "redacted_thinking", "data": redacted_blob},
|
|
],
|
|
"stop_reason": "end_turn",
|
|
"usage": {"input_tokens": 10, "output_tokens": 3},
|
|
}
|
|
|
|
sse_text = b"".join(parser._response_to_sse(response, "anthropic")).decode("utf-8")
|
|
|
|
assert "thinking_delta" in sse_text
|
|
assert "signature_delta" in sse_text
|
|
assert "citations_delta" in sse_text
|
|
assert "redacted_thinking" in sse_text
|
|
assert redacted_blob in sse_text
|
|
|
|
round_tripped = parser._parse_sse_to_response(sse_text, "anthropic")
|
|
assert round_tripped is not None
|
|
assert round_tripped["content"][0]["thinking"] == "plan carefully"
|
|
assert round_tripped["content"][0]["signature"] == "sig_123"
|
|
assert round_tripped["content"][1]["citations"][0]["cited_text"] == "abc"
|
|
assert round_tripped["content"][2]["data"] == redacted_blob
|
|
|
|
|
|
def test_response_to_sse_rejects_unknown_content_block() -> None:
|
|
parser = _Parser()
|
|
|
|
with pytest.raises(ValueError, match="Unsupported Anthropic content block type"):
|
|
parser._response_to_sse(
|
|
{"content": [{"type": "future_block", "payload": "preserve me"}]},
|
|
"anthropic",
|
|
)
|