Skip to content

Commit 1dd3713

Browse files
Anthropic Citations API Support (#8382)
* test(test_anthropic_completion.py): add test ensuring anthropic structured output response is consistent Resolves #8291 * feat(anthropic.py): support citations api with new user document message format Resolves #7970 * fix(anthropic/chat/transformation.py): return citations as a provider-specific-field Resolves #7970 * feat(anthropic/chat/handler.py): add streaming citations support Resolves #7970 * fix(handler.py): fix code qa error * fix(handler.py): only set provider specific fields if non-empty dict * docs(anthropic.md): add citations api to anthropic docs
1 parent b3de321 commit 1dd3713

File tree

9 files changed

+308
-21
lines changed

9 files changed

+308
-21
lines changed

Diff for: docs/my-website/docs/providers/anthropic.md

+100
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,106 @@ curl http://0.0.0.0:4000/v1/chat/completions \
987987
</TabItem>
988988
</Tabs>
989989

990+
## [BETA] Citations API
991+
992+
Pass `citations: {"enabled": true}` to Anthropic, to get citations on your document responses.
993+
994+
Note: This interface is in BETA. If you have feedback on how citations should be returned, please [tell us here](https://github.com/BerriAI/litellm/issues/7970#issuecomment-2644437943)
995+
996+
<Tabs>
997+
<TabItem value="sdk" label="SDK">
998+
999+
```python
1000+
from litellm import completion
1001+
1002+
resp = completion(
1003+
model="claude-3-5-sonnet-20241022",
1004+
messages=[
1005+
{
1006+
"role": "user",
1007+
"content": [
1008+
{
1009+
"type": "document",
1010+
"source": {
1011+
"type": "text",
1012+
"media_type": "text/plain",
1013+
"data": "The grass is green. The sky is blue.",
1014+
},
1015+
"title": "My Document",
1016+
"context": "This is a trustworthy document.",
1017+
"citations": {"enabled": True},
1018+
},
1019+
{
1020+
"type": "text",
1021+
"text": "What color is the grass and sky?",
1022+
},
1023+
],
1024+
}
1025+
],
1026+
)
1027+
1028+
citations = resp.choices[0].message.provider_specific_fields["citations"]
1029+
1030+
assert citations is not None
1031+
```
1032+
1033+
</TabItem>
1034+
<TabItem value="proxy" label="PROXY">
1035+
1036+
1. Setup config.yaml
1037+
1038+
```yaml
1039+
model_list:
1040+
- model_name: anthropic-claude
1041+
litellm_params:
1042+
model: anthropic/claude-3-5-sonnet-20241022
1043+
api_key: os.environ/ANTHROPIC_API_KEY
1044+
```
1045+
1046+
2. Start proxy
1047+
1048+
```bash
1049+
litellm --config /path/to/config.yaml
1050+
1051+
# RUNNING on http://0.0.0.0:4000
1052+
```
1053+
1054+
3. Test it!
1055+
1056+
```bash
1057+
curl -L -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
1058+
-H 'Content-Type: application/json' \
1059+
-H 'Authorization: Bearer sk-1234' \
1060+
-d '{
1061+
"model": "anthropic-claude",
1062+
"messages": [
1063+
{
1064+
"role": "user",
1065+
"content": [
1066+
{
1067+
"type": "document",
1068+
"source": {
1069+
"type": "text",
1070+
"media_type": "text/plain",
1071+
"data": "The grass is green. The sky is blue.",
1072+
},
1073+
"title": "My Document",
1074+
"context": "This is a trustworthy document.",
1075+
"citations": {"enabled": True},
1076+
},
1077+
{
1078+
"type": "text",
1079+
"text": "What color is the grass and sky?",
1080+
},
1081+
],
1082+
}
1083+
]
1084+
}'
1085+
```
1086+
1087+
</TabItem>
1088+
</Tabs>
1089+
9901090
## Usage - passing 'user_id' to Anthropic
9911091

9921092
LiteLLM translates the OpenAI `user` param to Anthropic's `metadata[user_id]` param.

Diff for: litellm/litellm_core_utils/prompt_templates/factory.py

+2
Original file line numberDiff line numberDiff line change
@@ -1421,6 +1421,8 @@ def anthropic_messages_pt( # noqa: PLR0915
14211421
)
14221422

14231423
user_content.append(_content_element)
1424+
elif m.get("type", "") == "document":
1425+
user_content.append(cast(AnthropicMessagesDocumentParam, m))
14241426
elif isinstance(user_message_types_block["content"], str):
14251427
_anthropic_content_text_element: AnthropicMessagesTextParam = {
14261428
"type": "text",

Diff for: litellm/litellm_core_utils/streaming_handler.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,10 @@ def return_processed_chunk_logic( # noqa
809809
if self.sent_first_chunk is False:
810810
completion_obj["role"] = "assistant"
811811
self.sent_first_chunk = True
812-
812+
if response_obj.get("provider_specific_fields") is not None:
813+
completion_obj["provider_specific_fields"] = response_obj[
814+
"provider_specific_fields"
815+
]
813816
model_response.choices[0].delta = Delta(**completion_obj)
814817
_index: Optional[int] = completion_obj.get("index")
815818
if _index is not None:

Diff for: litellm/llms/anthropic/chat/handler.py

+31-15
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import copy
66
import json
7-
from typing import Any, Callable, List, Optional, Tuple, Union
7+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
88

99
import httpx # type: ignore
1010

@@ -506,6 +506,29 @@ def _handle_usage(
506506

507507
return usage_block
508508

509+
def _content_block_delta_helper(self, chunk: dict):
510+
text = ""
511+
tool_use: Optional[ChatCompletionToolCallChunk] = None
512+
provider_specific_fields = {}
513+
content_block = ContentBlockDelta(**chunk) # type: ignore
514+
self.content_blocks.append(content_block)
515+
if "text" in content_block["delta"]:
516+
text = content_block["delta"]["text"]
517+
elif "partial_json" in content_block["delta"]:
518+
tool_use = {
519+
"id": None,
520+
"type": "function",
521+
"function": {
522+
"name": None,
523+
"arguments": content_block["delta"]["partial_json"],
524+
},
525+
"index": self.tool_index,
526+
}
527+
elif "citation" in content_block["delta"]:
528+
provider_specific_fields["citation"] = content_block["delta"]["citation"]
529+
530+
return text, tool_use, provider_specific_fields
531+
509532
def chunk_parser(self, chunk: dict) -> GenericStreamingChunk:
510533
try:
511534
type_chunk = chunk.get("type", "") or ""
@@ -515,27 +538,17 @@ def chunk_parser(self, chunk: dict) -> GenericStreamingChunk:
515538
is_finished = False
516539
finish_reason = ""
517540
usage: Optional[ChatCompletionUsageBlock] = None
541+
provider_specific_fields: Dict[str, Any] = {}
518542

519543
index = int(chunk.get("index", 0))
520544
if type_chunk == "content_block_delta":
521545
"""
522546
Anthropic content chunk
523547
chunk = {'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': 'Hello'}}
524548
"""
525-
content_block = ContentBlockDelta(**chunk) # type: ignore
526-
self.content_blocks.append(content_block)
527-
if "text" in content_block["delta"]:
528-
text = content_block["delta"]["text"]
529-
elif "partial_json" in content_block["delta"]:
530-
tool_use = {
531-
"id": None,
532-
"type": "function",
533-
"function": {
534-
"name": None,
535-
"arguments": content_block["delta"]["partial_json"],
536-
},
537-
"index": self.tool_index,
538-
}
549+
text, tool_use, provider_specific_fields = (
550+
self._content_block_delta_helper(chunk=chunk)
551+
)
539552
elif type_chunk == "content_block_start":
540553
"""
541554
event: content_block_start
@@ -628,6 +641,9 @@ def chunk_parser(self, chunk: dict) -> GenericStreamingChunk:
628641
finish_reason=finish_reason,
629642
usage=usage,
630643
index=index,
644+
provider_specific_fields=(
645+
provider_specific_fields if provider_specific_fields else None
646+
),
631647
)
632648

633649
return returned_chunk

Diff for: litellm/llms/anthropic/chat/transformation.py

+5
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,7 @@ def transform_response(
628628
)
629629
else:
630630
text_content = ""
631+
citations: List[Any] = []
631632
tool_calls: List[ChatCompletionToolCallChunk] = []
632633
for idx, content in enumerate(completion_response["content"]):
633634
if content["type"] == "text":
@@ -645,10 +646,14 @@ def transform_response(
645646
index=idx,
646647
)
647648
)
649+
## CITATIONS
650+
if content.get("citations", None) is not None:
651+
citations.append(content["citations"])
648652

649653
_message = litellm.Message(
650654
tool_calls=tool_calls,
651655
content=text_content or None,
656+
provider_specific_fields={"citations": citations},
652657
)
653658

654659
## HANDLE JSON MODE - anthropic returns single function call

Diff for: litellm/types/llms/anthropic.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,17 @@ class AnthropicMessagesImageParam(TypedDict, total=False):
9292
cache_control: Optional[Union[dict, ChatCompletionCachedContent]]
9393

9494

95+
class CitationsObject(TypedDict):
96+
enabled: bool
97+
98+
9599
class AnthropicMessagesDocumentParam(TypedDict, total=False):
96100
type: Required[Literal["document"]]
97101
source: Required[AnthropicContentParamSource]
98102
cache_control: Optional[Union[dict, ChatCompletionCachedContent]]
103+
title: str
104+
context: str
105+
citations: Optional[CitationsObject]
99106

100107

101108
class AnthropicMessagesToolResultContent(TypedDict):
@@ -173,6 +180,11 @@ class ContentTextBlockDelta(TypedDict):
173180
text: str
174181

175182

183+
class ContentCitationsBlockDelta(TypedDict):
184+
type: Literal["citations"]
185+
citation: dict
186+
187+
176188
class ContentJsonBlockDelta(TypedDict):
177189
"""
178190
"delta": {"type": "input_json_delta","partial_json": "{\"location\": \"San Fra"}}
@@ -185,7 +197,9 @@ class ContentJsonBlockDelta(TypedDict):
185197
class ContentBlockDelta(TypedDict):
186198
type: Literal["content_block_delta"]
187199
index: int
188-
delta: Union[ContentTextBlockDelta, ContentJsonBlockDelta]
200+
delta: Union[
201+
ContentTextBlockDelta, ContentJsonBlockDelta, ContentCitationsBlockDelta
202+
]
189203

190204

191205
class ContentBlockStop(TypedDict):

Diff for: litellm/types/llms/openai.py

+20
Original file line numberDiff line numberDiff line change
@@ -382,10 +382,29 @@ class ChatCompletionAudioObject(ChatCompletionContentPartInputAudioParam):
382382
pass
383383

384384

385+
class DocumentObject(TypedDict):
386+
type: Literal["text"]
387+
media_type: str
388+
data: str
389+
390+
391+
class CitationsObject(TypedDict):
392+
enabled: bool
393+
394+
395+
class ChatCompletionDocumentObject(TypedDict):
396+
type: Literal["document"]
397+
source: DocumentObject
398+
title: str
399+
context: str
400+
citations: Optional[CitationsObject]
401+
402+
385403
OpenAIMessageContentListBlock = Union[
386404
ChatCompletionTextObject,
387405
ChatCompletionImageObject,
388406
ChatCompletionAudioObject,
407+
ChatCompletionDocumentObject,
389408
]
390409

391410
OpenAIMessageContent = Union[
@@ -460,6 +479,7 @@ class ChatCompletionDeveloperMessage(OpenAIChatCompletionDeveloperMessage, total
460479
"text",
461480
"image_url",
462481
"input_audio",
482+
"document",
463483
] # used for validating user messages. Prevent users from accidentally sending anthropic messages.
464484

465485
AllMessageValues = Union[

Diff for: litellm/types/utils.py

+1
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,7 @@ def __init__(
551551
):
552552
super(Delta, self).__init__(**params)
553553
provider_specific_fields: Dict[str, Any] = {}
554+
554555
if "reasoning_content" in params:
555556
provider_specific_fields["reasoning_content"] = params["reasoning_content"]
556557
setattr(self, "reasoning_content", params["reasoning_content"])

0 commit comments

Comments
 (0)