Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/anthropic/_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def __stream__(self) -> Iterator[_T]:
if sse.event == "completion":
yield process_data(data=sse.json(), cast_to=cast_to, response=response)

if (
elif (
sse.event == "message_start"
or sse.event == "message_delta"
or sse.event == "message_stop"
Expand All @@ -100,10 +100,10 @@ def __stream__(self) -> Iterator[_T]:

yield process_data(data=data, cast_to=cast_to, response=response)

if sse.event == "ping":
elif sse.event == "ping":
continue

if sse.event == "error":
elif sse.event == "error":
body = sse.data

try:
Expand Down Expand Up @@ -206,7 +206,7 @@ async def __stream__(self) -> AsyncIterator[_T]:
if sse.event == "completion":
yield process_data(data=sse.json(), cast_to=cast_to, response=response)

if (
elif (
sse.event == "message_start"
or sse.event == "message_delta"
or sse.event == "message_stop"
Expand All @@ -221,10 +221,10 @@ async def __stream__(self) -> AsyncIterator[_T]:

yield process_data(data=data, cast_to=cast_to, response=response)

if sse.event == "ping":
elif sse.event == "ping":
continue

if sse.event == "error":
elif sse.event == "error":
body = sse.data

try:
Expand Down
6 changes: 6 additions & 0 deletions src/anthropic/types/beta/parsed_beta_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from .beta_container_upload_block import BetaContainerUploadBlock
from .beta_redacted_thinking_block import BetaRedactedThinkingBlock
from .beta_web_search_tool_result_block import BetaWebSearchToolResultBlock
from .beta_web_fetch_tool_result_block import BetaWebFetchToolResultBlock
from .beta_advisor_tool_result_block import BetaAdvisorToolResultBlock
from .beta_tool_search_tool_result_block import BetaToolSearchToolResultBlock
from .beta_code_execution_tool_result_block import BetaCodeExecutionToolResultBlock
from .beta_bash_code_execution_tool_result_block import BetaBashCodeExecutionToolResultBlock
from .beta_text_editor_code_execution_tool_result_block import BetaTextEditorCodeExecutionToolResultBlock
Expand Down Expand Up @@ -44,6 +47,9 @@ class ParsedBetaTextBlock(BetaTextBlock, Generic[ResponseFormatT]):
BetaToolUseBlock,
BetaServerToolUseBlock,
BetaWebSearchToolResultBlock,
BetaWebFetchToolResultBlock,
BetaAdvisorToolResultBlock,
BetaToolSearchToolResultBlock,
BetaCodeExecutionToolResultBlock,
BetaBashCodeExecutionToolResultBlock,
BetaTextEditorCodeExecutionToolResultBlock,
Expand Down
12 changes: 12 additions & 0 deletions src/anthropic/types/parsed_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
from .server_tool_use_block import ServerToolUseBlock
from .redacted_thinking_block import RedactedThinkingBlock
from .web_search_tool_result_block import WebSearchToolResultBlock
from .web_fetch_tool_result_block import WebFetchToolResultBlock
from .code_execution_tool_result_block import CodeExecutionToolResultBlock
from .bash_code_execution_tool_result_block import BashCodeExecutionToolResultBlock
from .text_editor_code_execution_tool_result_block import TextEditorCodeExecutionToolResultBlock
from .tool_search_tool_result_block import ToolSearchToolResultBlock
from .container_upload_block import ContainerUploadBlock

ResponseFormatT = TypeVar("ResponseFormatT", default=None)

Expand All @@ -37,6 +43,12 @@ class ParsedTextBlock(TextBlock, Generic[ResponseFormatT]):
ToolUseBlock,
ServerToolUseBlock,
WebSearchToolResultBlock,
WebFetchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
ToolSearchToolResultBlock,
ContainerUploadBlock,
],
PropertyInfo(discriminator="type"),
]
Expand Down
35 changes: 35 additions & 0 deletions tests/lib/streaming/fixtures/server_tool_result_response.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
event: message_start
data: {"type":"message_start","message":{"id":"msg_server_tool_result_test","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"svu_web_001","name":"web_search","input":{}}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tu_client_001","name":"ask_internal_tool","caller":{"type":"direct"},"input":{}}}

event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"test\"}"}}

event: content_block_stop
data: {"type":"content_block_stop","index":1}

event: content_block_start
data: {"type":"content_block_start","index":2,"content_block":{"type":"web_search_tool_result","tool_use_id":"svu_web_001","content":[{"type":"web_search_result","url":"https://example.com","title":"Example Result","encrypted_content":"enc_abc123","page_age":null}]}}

event: content_block_stop
data: {"type":"content_block_stop","index":2}

event: content_block_start
data: {"type":"content_block_start","index":3,"content_block":{"type":"code_execution_tool_result","tool_use_id":"svu_code_001","content":{"type":"code_execution_result","return_code":0,"stdout":"hello","stderr":"","content":[]}}}

event: content_block_stop
data: {"type":"content_block_stop","index":3}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":20}}

event: message_stop
data: {"type":"message_stop"}
81 changes: 81 additions & 0 deletions tests/lib/streaming/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,84 @@ def test_tracks_tool_input_type_alias_is_up_to_date() -> None:
f"ContentBlock type {block_type.__name__} has an input property, "
f"but is not included in TRACKS_TOOL_INPUT. You probably need to update the TRACKS_TOOL_INPUT type alias."
)


# Regression tests for https://github.com/anthropics/anthropic-sdk-python/issues/1325
#
# ParsedContentBlock was missing 5 of 6 server tool result types. When blocks like
# CodeExecutionToolResultBlock arrived as content_block_start events during streaming,
# construct_type() failed discriminated union parsing and silently dropped them from
# current_snapshot.content, so get_final_message().content was missing server tool results.


class TestServerToolResultBlocks:
@pytest.mark.respx(base_url=base_url)
def test_server_tool_result_blocks_not_dropped(self, respx_mock: MockRouter) -> None:
"""
Sync: server tool result blocks (web_search_tool_result, code_execution_tool_result)
must appear in get_final_message().content when streamed concurrently with client tools.
"""
respx_mock.post("/v1/messages").mock(
return_value=httpx.Response(200, content=get_response("server_tool_result_response.txt"))
)

with sync_client.messages.stream(
max_tokens=1024,
messages=[{"role": "user", "content": "search the web and run code"}],
model="claude-sonnet-4-20250514",
) as stream:
events = [event for event in stream]
final_message = stream.get_final_message()

block_types = [b.type for b in final_message.content]

assert len(final_message.content) == 4, (
f"Expected 4 content blocks, got {len(final_message.content)}. "
f"Types present: {block_types}. "
f"server tool result blocks are being silently dropped."
)
assert final_message.content[0].type == "server_tool_use"
assert final_message.content[1].type == "tool_use"
assert final_message.content[2].type == "web_search_tool_result"
assert final_message.content[3].type == "code_execution_tool_result"

# All 4 content_block_start events must have been yielded to the caller
block_start_events = [e for e in events if e.type == "content_block_start"]
assert len(block_start_events) == 4, (
f"Expected 4 content_block_start events, got {len(block_start_events)}"
)

@pytest.mark.asyncio
@pytest.mark.respx(base_url=base_url)
async def test_async_server_tool_result_blocks_not_dropped(self, respx_mock: MockRouter) -> None:
"""
Async: same regression test as above for the async streaming path.
"""
respx_mock.post("/v1/messages").mock(
return_value=httpx.Response(200, content=to_async_iter(get_response("server_tool_result_response.txt")))
)

async with async_client.messages.stream(
max_tokens=1024,
messages=[{"role": "user", "content": "search the web and run code"}],
model="claude-sonnet-4-20250514",
) as stream:
events = [event async for event in stream]
final_message = await stream.get_final_message()

block_types = [b.type for b in final_message.content]

assert len(final_message.content) == 4, (
f"Expected 4 content blocks, got {len(final_message.content)}. "
f"Types present: {block_types}. "
f"server tool result blocks are being silently dropped."
)
assert final_message.content[0].type == "server_tool_use"
assert final_message.content[1].type == "tool_use"
assert final_message.content[2].type == "web_search_tool_result"
assert final_message.content[3].type == "code_execution_tool_result"

block_start_events = [e for e in events if e.type == "content_block_start"]
assert len(block_start_events) == 4, (
f"Expected 4 content_block_start events, got {len(block_start_events)}"
)