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)