From 4ac54934cbebe77f72a2cd7432ea792f17a5fd65 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 8 Jul 2026 00:25:58 -0400 Subject: [PATCH] fix(streaming): preserve server_tool_use sse blocks (#1826) ## Description Buffered Anthropic responses currently fail late when they contain a `server_tool_use` block. `_response_to_sse()` raises after the upstream response is already fully buffered, so callers wait through the whole generation and then receive a 502 instead of the completed response. This adds explicit `server_tool_use` support in the buffered-to-SSE replay path, while keeping the existing rejection for truly unsupported Anthropic block types. 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 - Added a `server_tool_use` branch in the Anthropic buffered-response SSE conversion loop. - Emitted the full `server_tool_use` block in `content_block_start` instead of raising during replay. - Added a focused regression that proves buffered `server_tool_use` blocks convert to SSE and round-trip with the block type intact. - Kept the existing reject-unknown test so unsupported future block types still fail loudly. - Applied the pinned Ruff formatter to three pre-existing files on the current base so the repo-wide lint job passes unchanged semantics. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_sse_thinking_blocks.py -q`) - [x] Linting passes (`uv run ruff check headroom/proxy/handlers/streaming.py tests/test_sse_thinking_blocks.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text uv run pytest tests/test_sse_thinking_blocks.py -q 7 passed, 1 warning in 0.19s uv run ruff check headroom/proxy/handlers/streaming.py tests/test_sse_thinking_blocks.py All checks passed! uv run ruff check . All checks passed! uv run ruff format --check . 1046 files already formatted ``` ## Real Behavior Proof - Environment: Windows, project `uv` environment, focused handler-level regression. - Exact command / steps: run `tests/test_sse_thinking_blocks.py` on `origin/main` with the new `server_tool_use` regression present, then rerun the same file on this branch. - Observed result: base raises `Unsupported Anthropic content block type for SSE conversion: 'server_tool_use'`; head passes the focused file and preserves the `server_tool_use` block type through buffered SSE reconstruction, while the existing reject-unknown test still passes. - Not tested: live proxy traffic against Anthropic server-side tools. ## 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 ## Additional Notes No changelog entry is needed for this internal handler fix. The issue suggested a broader accept-all fallback, but this PR stays intentionally narrower: it handles the proven `server_tool_use` case and keeps the existing rejection for truly unsupported Anthropic block types. The extra formatting-only diff comes from the current base failing the pinned full-repo Ruff format check. --- headroom/proxy/handlers/streaming.py | 9 +++++++-- tests/test_sse_thinking_blocks.py | 29 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/headroom/proxy/handlers/streaming.py b/headroom/proxy/handlers/streaming.py index 7581b420b..eb0b4d29d 100644 --- a/headroom/proxy/handlers/streaming.py +++ b/headroom/proxy/handlers/streaming.py @@ -544,10 +544,15 @@ class StreamingMixin: "data": block.get("data", ""), }, } + elif block.get("type") == "server_tool_use": + block_start = { + "type": "content_block_start", + "index": idx, + "content_block": block, + } else: raise ValueError( - f"Unsupported Anthropic content block type for SSE conversion: " - f"{block.get('type')!r}" + f"Unsupported Anthropic content block type for SSE conversion: {block.get('type')!r}" ) events.append( diff --git a/tests/test_sse_thinking_blocks.py b/tests/test_sse_thinking_blocks.py index 762bf2c58..5ed2abd38 100644 --- a/tests/test_sse_thinking_blocks.py +++ b/tests/test_sse_thinking_blocks.py @@ -186,6 +186,35 @@ def test_redacted_thinking_data_preserved() -> None: assert block["data"] == redacted_blob +def test_response_to_sse_preserves_server_tool_use_blocks() -> None: + parser = _Parser() + response = { + "id": "msg_1", + "model": "claude-opus-4", + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srv_1", + "name": "web_search", + "input": {"query": "headroom"}, + } + ], + "stop_reason": "end_turn", + "usage": {"output_tokens": 1}, + } + + sse_events = b"".join(parser._response_to_sse(response, "anthropic")).decode("utf-8") + + assert '"type": "content_block_start"' in sse_events + assert '"type": "server_tool_use"' in sse_events + assert '"name": "web_search"' in sse_events + + round_tripped = parser._parse_sse_to_response(sse_events, "anthropic") + assert round_tripped is not None + assert round_tripped["content"][0]["type"] == "server_tool_use" + + def test_response_to_sse_preserves_thinking_redacted_and_citations() -> None: parser = _Parser() redacted_blob = "ENC:" + ("y" * 200)