diff --git a/src/strands/models/anthropic.py b/src/strands/models/anthropic.py index b5f6fcf91..7c41b8d12 100644 --- a/src/strands/models/anthropic.py +++ b/src/strands/models/anthropic.py @@ -11,6 +11,7 @@ from typing import Any, TypedDict, TypeVar, cast import anthropic +from anthropic.types import TextBlock from pydantic import BaseModel from typing_extensions import Required, Unpack, override @@ -254,6 +255,32 @@ def _format_tool_choice(tool_choice: ToolChoice | None) -> dict: else: return {} + @staticmethod + def _normalize_event(event: Any) -> Any: + """Normalize Anthropic stream events to avoid Pydantic serialization warnings. + + The Anthropic SDK returns ParsedTextBlock (a TextBlock subclass with an extra + parsed_output field) in content_block_start events. Calling model_dump() on those + events triggers PydanticSerializationUnexpectedValue warnings because ParsedTextBlock + is not in the discriminated union on Message.content. + + This converts any ParsedTextBlock back to a plain TextBlock so the discriminated + union matches cleanly and no warnings are emitted. + """ + try: + from anthropic.types.parsed_message import ParsedTextBlock + except ImportError: + return event + + if event.type == "content_block_start" and isinstance(event.content_block, ParsedTextBlock): + event.content_block = TextBlock( + text=event.content_block.text, + type=event.content_block.type, + citations=event.content_block.citations, + ) + + return event + def format_chunk(self, event: dict[str, Any]) -> StreamEvent: """Format the Anthropic response events into standardized message chunks. @@ -407,7 +434,7 @@ async def stream( logger.debug("got response from model") async for event in stream: if event.type in AnthropicModel.EVENT_TYPES: - yield self.format_chunk(event.model_dump()) + yield self.format_chunk(self._normalize_event(event).model_dump()) usage = event.message.usage # type: ignore yield self.format_chunk({"type": "metadata", "usage": usage.model_dump()}) diff --git a/tests/strands/models/test_anthropic.py b/tests/strands/models/test_anthropic.py index c5aff8062..e79c8ae14 100644 --- a/tests/strands/models/test_anthropic.py +++ b/tests/strands/models/test_anthropic.py @@ -933,3 +933,50 @@ def test_format_request_filters_location_source_document(model, model_id, max_to ] assert tru_request["messages"] == exp_messages assert "Location sources are not supported by Anthropic" in caplog.text + + +def test_normalize_event_converts_parsed_text_block(model): + """_normalize_event replaces ParsedTextBlock with TextBlock to avoid Pydantic warnings.""" + try: + from anthropic.types.parsed_message import ParsedTextBlock + except ImportError: + pytest.skip("ParsedTextBlock not available in this SDK version") + + from anthropic.types import TextBlock + + parsed_block = ParsedTextBlock(type="text", text="hello", citations=None) + mock_event = unittest.mock.Mock() + mock_event.type = "content_block_start" + mock_event.content_block = parsed_block + + result = AnthropicModel._normalize_event(mock_event) + + assert result is mock_event + assert isinstance(result.content_block, TextBlock) + assert not isinstance(result.content_block, ParsedTextBlock) + assert result.content_block.text == "hello" + + +def test_normalize_event_passes_through_text_block(model): + """_normalize_event leaves events with a plain TextBlock unchanged.""" + from anthropic.types import TextBlock + + plain_block = TextBlock(type="text", text="world") + mock_event = unittest.mock.Mock() + mock_event.type = "content_block_start" + mock_event.content_block = plain_block + + result = AnthropicModel._normalize_event(mock_event) + + assert result is mock_event + assert result.content_block is plain_block + + +def test_normalize_event_passes_through_non_content_block_start(model): + """_normalize_event leaves events with types other than content_block_start unchanged.""" + mock_event = unittest.mock.Mock() + mock_event.type = "message_start" + + result = AnthropicModel._normalize_event(mock_event) + + assert result is mock_event