mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
548e7ebd60
commit
2ae5999319
4 changed files with 338 additions and 11 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue