diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 5cc91b1efed..0d6dfabacb0 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -1366,6 +1366,16 @@ def _parse_contents_from_anthropic( annotations=self._parse_citations_from_anthropic(content_block), ) ) + case "citations_delta": + # Streaming delivers citations as separate deltas on the text block; + # surface them as an empty text carrying the annotation so they merge into it. + contents.append( + Content.from_text( + text="", + raw_representation=content_block, + annotations=self._parse_citations_from_anthropic(content_block), + ) + ) case "tool_use" | "mcp_tool_use" | "server_tool_use": request_state.active_call_id = content_block.id request_state.active_call_content_type = content_block.type @@ -1664,7 +1674,9 @@ def _parse_contents_from_anthropic( def _parse_citations_from_anthropic( self, content_block: BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock ) -> list[Annotation] | None: - content_blocks = getattr(content_block, "citations", None) + content_blocks: Any = getattr(content_block, "citations", None) + if content_block.type == "citations_delta": + content_blocks = [content_block.citation] if not content_blocks: return None annotations: list[Annotation] = [] diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 0b330a469cb..72fcb0c4888 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -36,9 +36,14 @@ ) from agent_framework.observability import ChatTelemetryLayer from anthropic.types.beta import ( + BetaCitationsDelta, + BetaCitationsWebSearchResultLocation, BetaMessage, BetaMessageDeltaUsage, + BetaRawContentBlockDeltaEvent, + BetaRawContentBlockStartEvent, BetaTextBlock, + BetaTextDelta, BetaToolUseBlock, BetaUsage, ) @@ -4194,6 +4199,55 @@ def test_parse_citations_search_result_location( assert len(result) > 0 +async def test_streaming_citations_delta_surfaces_annotations(mock_anthropic_client: MagicMock) -> None: + """Citations streamed as citations_delta events must end up as annotations, like non-streaming.""" + citation = BetaCitationsWebSearchResultLocation( + type="web_search_result_location", + url="https://example.com/paris", + title="Paris weather", + cited_text="Paris is sunny today.", + encrypted_index="enc-1", + ) + + async def events() -> Any: + yield _message_start_event("msg_cited") + yield BetaRawContentBlockStartEvent( + type="content_block_start", index=0, content_block=BetaTextBlock(type="text", text="", citations=None) + ) + yield BetaRawContentBlockDeltaEvent( + type="content_block_delta", index=0, delta=BetaCitationsDelta(type="citations_delta", citation=citation) + ) + yield BetaRawContentBlockDeltaEvent( + type="content_block_delta", index=0, delta=BetaTextDelta(type="text_delta", text="It is sunny in Paris.") + ) + yield _content_block_stop_event() + yield _message_delta_event("end_turn") + yield _message_stop_event() + + async def create(**kwargs: Any) -> Any: + return events() + + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.side_effect = create + stream = client.get_response([Message(role="user", contents=["Weather in Paris?"])], stream=True) + async for _ in stream: + pass + response = await stream.get_final_response() + + assert response.text == "It is sunny in Paris." + annotations = [ + annotation + for message in response.messages + for content in message.contents + if content.type == "text" + for annotation in content.annotations or [] + ] + assert len(annotations) == 1 + assert annotations[0]["url"] == "https://example.com/paris" + assert annotations[0]["title"] == "Paris weather" + assert annotations[0]["snippet"] == "Paris is sunny today." + + @pytest.mark.flaky @pytest.mark.integration @skip_if_anthropic_integration_tests_disabled