From 2ae599931973cf92d641864a2b9994f48d9b95e9 Mon Sep 17 00:00:00 2001 From: Di-Is Date: Tue, 17 Mar 2026 04:57:23 +0900 Subject: [PATCH] fix: translate Anthropic web search citations to message.annotations (#23691) * fix: translate Anthropic web search citations to message.annotations Anthropic's web_search_result_location citations were only stored in provider_specific_fields["citations"], leaving message.annotations empty. This caused the Responses API to return empty URL/title for citations. - Add citation-to-annotation translation helpers in AnthropicConfig - Populate message.annotations in non-streaming responses - Accumulate citation deltas and batch-emit in streaming message_delta - Initialize _pending_annotation_events in __init__ and drain in common_done_event_logic to prevent annotation loss at end-of-stream * fix: call staticmethod via class instead of unnecessary instantiation * fix: skip citation when url is absent instead of passing None --- litellm/llms/anthropic/chat/handler.py | 15 + litellm/llms/anthropic/chat/transformation.py | 50 ++++ .../streaming_iterator.py | 18 +- .../test_anthropic_chat_transformation.py | 266 ++++++++++++++++++ 4 files changed, 338 insertions(+), 11 deletions(-) diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 72cc7ecd9cc..3ba99cedb93 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -40,6 +40,7 @@ from litellm.types.llms.anthropic import ( UsageDelta, ) from litellm.types.llms.openai import ( + ChatCompletionAnnotation, ChatCompletionRedactedThinkingBlock, ChatCompletionThinkingBlock, ChatCompletionToolCallChunk, @@ -536,6 +537,8 @@ class ModelResponseIterator: # Accumulate compaction blocks for multi-turn reconstruction self.compaction_blocks: List[Dict[str, Any]] = [] + self._accumulated_annotations: List[ChatCompletionAnnotation] = [] + def check_empty_tool_call_args(self) -> bool: """ Check if the tool call block so far has been an empty string @@ -607,6 +610,13 @@ class ModelResponseIterator: ) elif "citation" in content_block["delta"]: provider_specific_fields["citation"] = content_block["delta"]["citation"] + annotation = ( + AnthropicConfig._translate_anthropic_citation_to_openai_annotation( + content_block["delta"]["citation"] + ) + ) + if annotation is not None: + self._accumulated_annotations.append(annotation) elif ( "thinking" in content_block["delta"] or "signature" in content_block["delta"] @@ -697,6 +707,7 @@ class ModelResponseIterator: ] ] ] = None + annotations_to_emit: Optional[List[ChatCompletionAnnotation]] = None # Always use index=0 for OpenAI choice format (fixes multi-choice errors) index = 0 @@ -838,6 +849,9 @@ class ModelResponseIterator: finish_reason, usage, container = self._handle_message_delta(chunk) if container: provider_specific_fields["container"] = container + if self._accumulated_annotations: + annotations_to_emit = list(self._accumulated_annotations) + self._accumulated_annotations = [] elif type_chunk == "message_start": """ Anthropic @@ -892,6 +906,7 @@ class ModelResponseIterator: thinking_blocks if thinking_blocks else None ), reasoning_content=reasoning_content, + annotations=annotations_to_emit, ), finish_reason=finish_reason, ) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 47cdd8287e0..f784f85b660 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -39,6 +39,8 @@ from litellm.types.llms.anthropic import ( from litellm.types.llms.openai import ( REASONING_EFFORT, AllMessageValues, + ChatCompletionAnnotation, + ChatCompletionAnnotationURLCitation, ChatCompletionCachedContent, ChatCompletionRedactedThinkingBlock, ChatCompletionSystemMessage, @@ -1682,6 +1684,48 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) return usage + @staticmethod + def _translate_anthropic_citation_to_openai_annotation( + citation: dict, + ) -> Optional[ChatCompletionAnnotation]: + citation_type = citation.get("type") + + if citation_type == "web_search_result_location": + url = citation.get("url") + if url is None: + return None + url_citation = ChatCompletionAnnotationURLCitation( + url=url, + title=citation.get("title"), + ) + else: + return None + + return ChatCompletionAnnotation( + type="url_citation", + url_citation=url_citation, + ) + + @staticmethod + def _translate_anthropic_citations_to_openai_annotations( + citations: Optional[List[List[dict]]], + ) -> Optional[List[ChatCompletionAnnotation]]: + if not citations: + return None + + annotations: List[ChatCompletionAnnotation] = [] + for citation_group in citations: + for citation in citation_group: + annotation = ( + AnthropicConfig._translate_anthropic_citation_to_openai_annotation( + citation + ) + ) + if annotation is not None: + annotations.append(annotation) + + return annotations if annotations else None + def transform_parsed_response( self, completion_response: dict, @@ -1754,12 +1798,18 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if compaction_blocks is not None: provider_specific_fields["compaction_blocks"] = compaction_blocks + annotations = ( + AnthropicConfig._translate_anthropic_citations_to_openai_annotations( + citations + ) + ) _message = litellm.Message( tool_calls=tool_calls, content=text_content or None, provider_specific_fields=provider_specific_fields, thinking_blocks=thinking_blocks, reasoning_content=reasoning_content, + annotations=annotations, ) _message.provider_specific_fields = provider_specific_fields diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index ce037850b86..f9ce472e6d1 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -88,6 +88,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._cached_item_id: Optional[str] = None self._cached_response_id: Optional[str] = None self._pending_tool_events: List[BaseLiteLLMOpenAIResponseObject] = [] + self._pending_annotation_events: List[BaseLiteLLMOpenAIResponseObject] = [] self._tool_output_index_by_call_id: dict[str, int] = {} self._tool_args_by_call_id: dict[str, str] = {} self._tool_call_id_by_index: dict[int, str] = {} @@ -748,6 +749,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): if self._pending_tool_events: return self._pending_tool_events.pop(0) + if self._pending_annotation_events: + return self._pending_annotation_events.pop(0) + done_event = self.return_default_done_events(self.litellm_model_response) if done_event: return done_event @@ -1001,14 +1005,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._cached_item_id = chunk.id item_id = self._cached_item_id or chunk.id - # Check if this chunk has annotations first (before processing text/reasoning) - # This ensures we detect and queue annotation events from the annotation chunk if chunk.choices and hasattr(chunk.choices[0].delta, "annotations"): annotations = chunk.choices[0].delta.annotations if annotations and self.sent_annotation_events is False: self.sent_annotation_events = True - # Store annotation events to emit them one by one - if not hasattr(self, "_pending_annotation_events"): + if not self._pending_annotation_events: response_annotations = LiteLLMCompletionResponsesConfig._transform_chat_completion_annotations_to_response_output_annotations( annotations=annotations ) @@ -1070,13 +1071,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): return self._pending_tool_events.pop(0) # Priority 4: If we have pending annotation events, emit the next one - # This happens when the current chunk has no text/reasoning content - if ( - hasattr(self, "_pending_annotation_events") - and self._pending_annotation_events - ): - event = self._pending_annotation_events.pop(0) - return event + if self._pending_annotation_events: + return self._pending_annotation_events.pop(0) # Priority 5: If we have pending tool events (from earlier chunk), emit the next one if self._pending_tool_events: diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index a95b9413b9d..4daa8597e6f 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -3321,3 +3321,269 @@ def test_map_tool_helper_empty_parameters_get_default(): assert result is not None assert result["input_schema"]["type"] == "object" assert result["input_schema"].get("properties") == {} + +MOCK_WEB_SEARCH_RESULT_LOCATION_CITATION = { + "type": "web_search_result_location", + "cited_text": "The Sony WH-1000XM5 remains one of the best...", + "url": "https://example.com/headphones", + "title": "Best Headphones 2025", +} + +MOCK_CHAR_LOCATION_CITATION = { + "type": "char_location", + "cited_text": "The grass is green.", + "document_index": 0, + "document_title": "My Document", + "start_char_index": 0, + "end_char_index": 20, +} + +MOCK_PAGE_LOCATION_CITATION = { + "type": "page_location", + "cited_text": "Chapter introduction.", + "document_index": 1, + "document_title": "User Manual", + "start_page_number": 3, + "end_page_number": 5, +} + +MOCK_CHAR_LOCATION_CITATION_NO_TITLE = { + "type": "char_location", + "cited_text": "Some text.", + "document_index": 0, + "start_char_index": 0, + "end_char_index": 10, +} + +MOCK_CITATION_WITH_SUPPORTED_TEXT = { + **MOCK_WEB_SEARCH_RESULT_LOCATION_CITATION, + "supported_text": "Based on current reviews...", +} + + +def test_web_search_result_location_citation_to_annotation(): + config = AnthropicConfig() + result = config._translate_anthropic_citation_to_openai_annotation( + MOCK_WEB_SEARCH_RESULT_LOCATION_CITATION + ) + assert result is not None + assert result["type"] == "url_citation" + assert result["url_citation"]["url"] == "https://example.com/headphones" + assert result["url_citation"]["title"] == "Best Headphones 2025" + assert "start_index" not in result["url_citation"] + assert "end_index" not in result["url_citation"] + + +def test_char_location_citation_to_annotation(): + config = AnthropicConfig() + result = config._translate_anthropic_citation_to_openai_annotation( + MOCK_CHAR_LOCATION_CITATION + ) + assert result is None + + +def test_page_location_citation_to_annotation(): + config = AnthropicConfig() + result = config._translate_anthropic_citation_to_openai_annotation( + MOCK_PAGE_LOCATION_CITATION + ) + assert result is None + + +def test_mixed_citation_types_batch_conversion(): + config = AnthropicConfig() + citations = [ + [MOCK_WEB_SEARCH_RESULT_LOCATION_CITATION], + [MOCK_CHAR_LOCATION_CITATION, MOCK_PAGE_LOCATION_CITATION], + ] + result = config._translate_anthropic_citations_to_openai_annotations(citations) + assert result is not None + assert len(result) == 1 # Only web_search_result_location survives + assert result[0]["url_citation"]["url"] == "https://example.com/headphones" + + +def test_unknown_citation_type_skipped(): + config = AnthropicConfig() + unknown = {"type": "future_type", "data": "something"} + citations = [[unknown, MOCK_WEB_SEARCH_RESULT_LOCATION_CITATION]] + result = config._translate_anthropic_citations_to_openai_annotations(citations) + assert result is not None + assert len(result) == 1 + assert result[0]["url_citation"]["url"] == "https://example.com/headphones" + + +def test_citations_none_empty_cases(): + config = AnthropicConfig() + assert config._translate_anthropic_citations_to_openai_annotations(None) is None + assert config._translate_anthropic_citations_to_openai_annotations([]) is None + assert config._translate_anthropic_citations_to_openai_annotations([[]]) is None + # Mixed empty and valid + result = config._translate_anthropic_citations_to_openai_annotations( + [[], [MOCK_WEB_SEARCH_RESULT_LOCATION_CITATION]] + ) + assert result is not None + assert len(result) == 1 + + +def test_document_title_none_produces_none_for_char_location(): + """char_location citations are skipped regardless of document_title presence.""" + config = AnthropicConfig() + result = config._translate_anthropic_citation_to_openai_annotation( + MOCK_CHAR_LOCATION_CITATION_NO_TITLE + ) + assert result is None + + +def test_supported_text_not_in_annotation(): + config = AnthropicConfig() + result = config._translate_anthropic_citation_to_openai_annotation( + MOCK_CITATION_WITH_SUPPORTED_TEXT + ) + assert result is not None + url_citation = result["url_citation"] + assert "supported_text" not in url_citation + assert url_citation["url"] == "https://example.com/headphones" + + +def test_backward_compat_provider_specific_fields_and_annotations(): + import httpx + from litellm.types.utils import ModelResponse + + config = AnthropicConfig() + completion_response = { + "id": "msg_01ABC123", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5-20251001", + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + "content": [ + { + "type": "text", + "text": "Based on reviews, Sony WH-1000XM5 is recommended.", + "citations": [ + { + "type": "web_search_result_location", + "cited_text": "Sony...", + "url": "https://example.com/headphones", + "title": "Best Headphones 2025", + } + ], + } + ], + } + mock_response = httpx.Response( + status_code=200, + json=completion_response, + request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"), + ) + model_response = ModelResponse() + config.transform_parsed_response( + completion_response=completion_response, + raw_response=mock_response, + model_response=model_response, + ) + message = model_response.choices[0].message + assert message.provider_specific_fields is not None + assert message.provider_specific_fields["citations"] is not None + assert message.annotations is not None + assert len(message.annotations) == 1 + assert message.annotations[0]["type"] == "url_citation" + assert ( + message.annotations[0]["url_citation"]["url"] + == "https://example.com/headphones" + ) + + +def test_streaming_message_delta_batch_emit(): + from litellm.llms.anthropic.chat.handler import ModelResponseIterator + + iterator = ModelResponseIterator(streaming_response=iter([]), sync_stream=True) + + content_block_1 = { + "type": "content_block_delta", + "index": 2, + "delta": { + "type": "citations", + "citation": { + "type": "web_search_result_location", + "cited_text": "Sony...", + "url": "https://example.com/1", + "title": "Title 1", + }, + }, + } + content_block_2 = { + "type": "content_block_delta", + "index": 2, + "delta": { + "type": "citations", + "citation": { + "type": "web_search_result_location", + "cited_text": "Bose...", + "url": "https://example.com/2", + "title": "Title 2", + }, + }, + } + + iterator._content_block_delta_helper(content_block_1) + iterator._content_block_delta_helper(content_block_2) + assert len(iterator._accumulated_annotations) == 2 + + message_delta_chunk = { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 50}, + } + result = iterator.chunk_parser(message_delta_chunk) + delta = result.choices[0].delta + assert hasattr(delta, "annotations") and delta.annotations is not None + assert len(delta.annotations) == 2 + assert delta.annotations[0]["url_citation"]["url"] == "https://example.com/1" + assert delta.annotations[1]["url_citation"]["url"] == "https://example.com/2" + assert len(iterator._accumulated_annotations) == 0 + + +def test_streaming_annotation_drain_in_common_done_event_logic(): + """Verify that annotations queued during streaming are fully drained at end-of-stream.""" + from litellm.llms.anthropic.chat.handler import ModelResponseIterator + + iterator = ModelResponseIterator(streaming_response=iter([]), sync_stream=True) + + for i in range(4): + content_block = { + "type": "content_block_delta", + "index": 2, + "delta": { + "type": "citations", + "citation": { + "type": "web_search_result_location", + "cited_text": f"Text {i}", + "url": f"https://example.com/{i}", + "title": f"Title {i}", + }, + }, + } + iterator._content_block_delta_helper(content_block) + + assert len(iterator._accumulated_annotations) == 4 + + message_delta_chunk = { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 50}, + } + result = iterator.chunk_parser(message_delta_chunk) + delta = result.choices[0].delta + assert hasattr(delta, "annotations") and delta.annotations is not None + assert len(delta.annotations) == 4 + for i in range(4): + assert delta.annotations[i]["url_citation"]["url"] == f"https://example.com/{i}" + assert len(iterator._accumulated_annotations) == 0