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 <mxjerrett@gmail.com>
This commit is contained in:
Matt Van Horn 2026-07-09 19:45:40 -07:00 committed by GitHub
parent d2a86b5909
commit d05802b620
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 58 additions and 11 deletions

View file

@ -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 `<x-headroom-base-url>/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)

View file

@ -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()

View file

@ -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