From d05802b6200b94f198e99319e6c778e78b53db8b Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 9 Jul 2026 19:45:40 -0700 Subject: [PATCH] fix: emit unknown Anthropic content blocks verbatim in buffered-to-SSE conversion (#1825) ## Description Unknown Anthropic content block types are now emitted verbatim inside `content_block_start` during buffered-to-SSE conversion instead of raising `ValueError`. The block-start loop in `StreamingMixin._response_to_sse` (`headroom/proxy/handlers/streaming.py`) previously handled only `text`, `tool_use`, `thinking`, and `redacted_thinking`; any other type fell through to a hard raise, which turned a fully-generated upstream response into an HTTP 502. Closes #1806 ## 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 - Emit unknown content block types, including `server_tool_use`, `server_tool_result`, `mcp_tool_use`, and future Anthropic block types, verbatim in `content_block_start` with no delta. - Preserve main's explicit `server_tool_use` support and newer buffered CCR/thinking regression coverage after merging current main. - Keep `content_block_delta` generation gated on known delta-capable block types, so unknown blocks do not produce spurious deltas. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text uv run --with pytest python -m pytest tests/test_sse_thinking_blocks.py -q 12 passed, 1 warning uv run --with ruff==0.15.17 ruff check headroom/proxy/handlers/streaming.py tests/test_sse_thinking_blocks.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows worktree `C:\git\headroom-governance-main`, PR head `45934b94`. - Exact command / steps: Merged current `headroomlabs/main`, then ran the targeted SSE pytest command and Ruff check shown above. - Observed result: Targeted SSE tests passed with 12 tests, and unknown/server_tool_use content blocks round-trip verbatim in `content_block_start`; before this change the same input raised `ValueError: Unsupported Anthropic content block type for SSE conversion: 'server_tool_use'` after the full generation had already been buffered, surfacing to the client as a 502 and a full multi-minute retry. - Not tested: End-to-end against a live upstream that emits server-side tool blocks. ## 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 - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] 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 The prior `test_response_to_sse_rejects_unknown_content_block` is replaced by `test_response_to_sse_emits_unknown_content_block_verbatim`; current main's newer buffered CCR/thinking tests are preserved after the merge from main. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: JerrettDavis --- CHANGELOG.md | 1 + headroom/proxy/handlers/streaming.py | 8 ++-- tests/test_sse_thinking_blocks.py | 60 ++++++++++++++++++++++++---- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f017f9ce..00062b2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **proxy:** The Anthropic Messages route (`POST /v1/messages`) now honors the `x-headroom-base-url` per-request upstream override. It previously ignored the header and always forwarded to `api.anthropic.com`, so clients that speak the Anthropic Messages wire format while authenticating against a non-Anthropic gateway (e.g. OpenCode Zen) were rejected upstream with `401 invalid x-api-key`. The route now forwards to `/v1/messages`, consistent with the OpenAI-compatible and passthrough routes ([#1760](https://github.com/headroomlabs-ai/headroom/issues/1760)). * **proxy:** the savings store now fsyncs its parent directory after the atomic rename, so the most recent `proxy_savings.json` write survives a power-loss or crash. `_save_locked` fsynced the temp file's contents but never the directory entry the rename created, leaving the rename itself non-durable on POSIX. Best-effort — a no-op on Windows and virtual filesystems where directory fsync is unsupported. - **code:** fix two `CodeAwareCompressor` AST-reassembly bugs: an exported JS/TS function or class (`export function foo() {`) produced a duplicated `export export` keyword and invalid syntax, because line-based node slicing (used to preserve indentation) pulled in the preceding `export` sibling's text on top of the `export_statement` handler's own prefix reconstruction. Separately, in every supported language, a doc comment immediately above a top-level function, class, or type was detached from its declaration during extraction and re-emitted in a cluster at the end of the compressed output instead of staying attached to what it documents. +- * **proxy:** Buffered upstream responses containing a `server_tool_use` (or any other unrecognized Anthropic content block) no longer turn a fully-generated response into an HTTP 502. `StreamingMixin._response_to_sse` raised `ValueError` on unknown block types after the entire upstream generation had already been buffered, so a slow-but-successful response failed and the client retried the whole multi-minute request. Unknown blocks are now emitted verbatim in `content_block_start` (following the existing redacted_thinking` pattern), so `server_tool_use`, `server_tool_result`, `mcp_tool_use`, and future block types round-trip ([#1806](https://github.com/headroomlabs-ai/headroom/issues/1806)). ## [0.31.0](https://github.com/headroomlabs-ai/headroom/compare/v0.30.0...v0.31.0) (2026-07-09) diff --git a/headroom/proxy/handlers/streaming.py b/headroom/proxy/handlers/streaming.py index 51efd402e..e31caf491 100644 --- a/headroom/proxy/handlers/streaming.py +++ b/headroom/proxy/handlers/streaming.py @@ -571,9 +571,11 @@ class StreamingMixin: "content_block": block, } else: - raise ValueError( - f"Unsupported Anthropic content block type for SSE conversion: {block.get('type')!r}" - ) + block_start = { + "type": "content_block_start", + "index": idx, + "content_block": block, + } events.append( f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode() diff --git a/tests/test_sse_thinking_blocks.py b/tests/test_sse_thinking_blocks.py index 46ca62cab..1efdaf579 100644 --- a/tests/test_sse_thinking_blocks.py +++ b/tests/test_sse_thinking_blocks.py @@ -23,8 +23,6 @@ from __future__ import annotations import json from typing import Any -import pytest - from headroom.proxy.handlers.streaming import StreamingMixin @@ -42,6 +40,15 @@ def _build_sse(events: list[dict[str, Any]]) -> str: return "\n".join(out) + "\n" +def _sse_events(sse_text: str) -> list[dict[str, Any]]: + """Extract JSON data objects from an SSE payload string.""" + events: list[dict[str, Any]] = [] + for line in sse_text.splitlines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + return events + + def test_thinking_delta_accumulated() -> None: parser = _Parser() events = [ @@ -275,14 +282,51 @@ def test_response_to_sse_does_not_default_missing_stop_reason() -> None: assert "end_turn" not in sse_text -def test_response_to_sse_rejects_unknown_content_block() -> None: +def test_response_to_sse_emits_unknown_content_block_verbatim() -> None: parser = _Parser() + block = {"type": "future_block", "payload": {"preserve": ["me"]}} - with pytest.raises(ValueError, match="Unsupported Anthropic content block type"): - parser._response_to_sse( - {"content": [{"type": "future_block", "payload": "preserve me"}]}, - "anthropic", - ) + sse_text = b"".join(parser._response_to_sse({"content": [block]}, "anthropic")).decode("utf-8") + events = _sse_events(sse_text) + + block_start = next(ev for ev in events if ev["type"] == "content_block_start") + assert block_start["content_block"] == block + assert not any(ev["type"] == "content_block_delta" for ev in events) + + +def test_response_to_sse_emits_server_tool_use_without_delta() -> None: + parser = _Parser() + server_tool_use = { + "type": "server_tool_use", + "id": "srvtoolu_123", + "name": "web_search", + "input": {"query": "headroom server_tool_use SSE crash"}, + } + response = { + "id": "msg_3", + "model": "claude-opus-4", + "role": "assistant", + "content": [ + {"type": "text", "text": "Searching."}, + server_tool_use, + ], + "stop_reason": "end_turn", + "usage": {"output_tokens": 5}, + } + + sse_text = b"".join(parser._response_to_sse(response, "anthropic")).decode("utf-8") + events = _sse_events(sse_text) + + block_starts = [ev for ev in events if ev["type"] == "content_block_start"] + assert block_starts[1]["index"] == 1 + assert block_starts[1]["content_block"] == server_tool_use + assert not any(ev["type"] == "content_block_delta" and ev["index"] == 1 for ev in events) + assert any( + ev["type"] == "content_block_delta" + and ev["index"] == 0 + and ev["delta"] == {"type": "text_delta", "text": "Searching."} + for ev in events + ) # Issue #1876: CCR buffered-stream re-synthesis corrupted extended-thinking