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
This commit is contained in:
Di-Is 2026-03-17 04:57:23 +09:00 • committed by GitHub
parent 548e7ebd60
commit 2ae5999319
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 338 additions and 11 deletions

View file

@ -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,
)

View file

@ -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

View file

@ -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:

View file

@ -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