mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
d2a86b5909
commit
d05802b620
3 changed files with 58 additions and 11 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue