Skip to content

Commit e91eda9

Browse files
sobychackomarkpollack
authored andcommitted
feat(anthropic): add Citations API support
Implement Anthropic's Citations API to enable Claude to reference specific parts of provided documents when generating responses. New classes: - `Citation`: Represents citation metadata with three location types (`CHAR_LOCATION`, `PAGE_LOCATION`, `CONTENT_BLOCK_LOCATION`) - `CitationDocument`: Builder for creating citation-enabled documents supporting plain text, PDF, and custom content blocks Implementation changes: - `AnthropicChatModel`: Extract citations from API responses and aggregate via `CitationContext` into response metadata - `AnthropicChatOptions`: Add `citationDocuments` configuration field - `AnthropicApi`: Add `CitationResponse` and `CitationsConfig` records Design decisions: - Static factory methods for `Citation` instantiation - Response-level metadata only (no message-level storage) - Opt-in behavior with `citationsEnabled` defaulting to false - Defensive logging for unexpected citation object types Testing: - Integration tests for plain text, PDF, custom content, and multiple document scenarios Documentation: - Reference documentation in `anthropic-chat.adoc` with usage examples, real-world use cases, and best practices Supported on Claude 3.7 Sonnet and Claude 4 models. Signed-off-by: Soby Chacko <[email protected]>
1 parent 58cf35e commit e91eda9

File tree

8 files changed

+1527
-21
lines changed

8 files changed

+1527
-21
lines changed

models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.springframework.ai.anthropic.api.AnthropicApi.Role;
4545
import org.springframework.ai.anthropic.api.AnthropicCacheOptions;
4646
import org.springframework.ai.anthropic.api.AnthropicCacheTtl;
47+
import org.springframework.ai.anthropic.api.CitationDocument;
4748
import org.springframework.ai.anthropic.api.utils.CacheEligibilityResolver;
4849
import org.springframework.ai.chat.messages.AssistantMessage;
4950
import org.springframework.ai.chat.messages.Message;
@@ -322,12 +323,13 @@ private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion, Usage
322323

323324
List<Generation> generations = new ArrayList<>();
324325
List<AssistantMessage.ToolCall> toolCalls = new ArrayList<>();
326+
CitationContext citationContext = new CitationContext();
325327
for (ContentBlock content : chatCompletion.content()) {
326328
switch (content.type()) {
327329
case TEXT, TEXT_DELTA:
328-
generations.add(new Generation(
329-
AssistantMessage.builder().content(content.text()).properties(Map.of()).build(),
330-
ChatGenerationMetadata.builder().finishReason(chatCompletion.stopReason()).build()));
330+
Generation textGeneration = processTextContent(content, chatCompletion.stopReason(),
331+
citationContext);
332+
generations.add(textGeneration);
331333
break;
332334
case THINKING, THINKING_DELTA:
333335
Map<String, Object> thinkingProperties = new HashMap<>();
@@ -371,7 +373,101 @@ private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion, Usage
371373
ChatGenerationMetadata.builder().finishReason(chatCompletion.stopReason()).build());
372374
generations.add(toolCallGeneration);
373375
}
374-
return new ChatResponse(generations, this.from(chatCompletion, usage));
376+
377+
// Create response metadata with citation information if present
378+
ChatResponseMetadata.Builder metadataBuilder = ChatResponseMetadata.builder()
379+
.id(chatCompletion.id())
380+
.model(chatCompletion.model())
381+
.usage(usage)
382+
.keyValue("stop-reason", chatCompletion.stopReason())
383+
.keyValue("stop-sequence", chatCompletion.stopSequence())
384+
.keyValue("type", chatCompletion.type());
385+
386+
// Add citation metadata if citations were found
387+
if (citationContext.hasCitations()) {
388+
metadataBuilder.keyValue("citations", citationContext.getAllCitations())
389+
.keyValue("citationCount", citationContext.getTotalCitationCount());
390+
}
391+
392+
ChatResponseMetadata responseMetadata = metadataBuilder.build();
393+
394+
return new ChatResponse(generations, responseMetadata);
395+
}
396+
397+
private Generation processTextContent(ContentBlock content, String stopReason, CitationContext citationContext) {
398+
// Extract citations if present in the content block
399+
if (content.citations() instanceof List) {
400+
try {
401+
@SuppressWarnings("unchecked")
402+
List<Object> citationObjects = (List<Object>) content.citations();
403+
404+
List<Citation> citations = new ArrayList<>();
405+
for (Object citationObj : citationObjects) {
406+
if (citationObj instanceof Map) {
407+
// Convert Map to CitationResponse using manual parsing
408+
AnthropicApi.CitationResponse citationResponse = parseCitationFromMap((Map<?, ?>) citationObj);
409+
citations.add(convertToCitation(citationResponse));
410+
}
411+
else {
412+
logger.warn("Unexpected citation object type: {}. Expected Map but got: {}. Skipping citation.",
413+
citationObj.getClass().getName(), citationObj);
414+
}
415+
}
416+
417+
if (!citations.isEmpty()) {
418+
citationContext.addCitations(citations);
419+
}
420+
421+
}
422+
catch (Exception e) {
423+
logger.warn("Failed to parse citations from content block", e);
424+
}
425+
}
426+
427+
return new Generation(new AssistantMessage(content.text()),
428+
ChatGenerationMetadata.builder().finishReason(stopReason).build());
429+
}
430+
431+
/**
432+
* Parse citation data from Map (typically from JSON deserialization). Assumes all
433+
* required fields are present and of correct types.
434+
* @param citationMap the map containing citation data from API response
435+
* @return parsed CitationResponse
436+
*/
437+
private AnthropicApi.CitationResponse parseCitationFromMap(Map<?, ?> citationMap) {
438+
String type = (String) citationMap.get("type");
439+
String citedText = (String) citationMap.get("cited_text");
440+
Integer documentIndex = (Integer) citationMap.get("document_index");
441+
String documentTitle = (String) citationMap.get("document_title");
442+
443+
Integer startCharIndex = (Integer) citationMap.get("start_char_index");
444+
Integer endCharIndex = (Integer) citationMap.get("end_char_index");
445+
Integer startPageNumber = (Integer) citationMap.get("start_page_number");
446+
Integer endPageNumber = (Integer) citationMap.get("end_page_number");
447+
Integer startBlockIndex = (Integer) citationMap.get("start_block_index");
448+
Integer endBlockIndex = (Integer) citationMap.get("end_block_index");
449+
450+
return new AnthropicApi.CitationResponse(type, citedText, documentIndex, documentTitle, startCharIndex,
451+
endCharIndex, startPageNumber, endPageNumber, startBlockIndex, endBlockIndex);
452+
}
453+
454+
/**
455+
* Convert CitationResponse to Citation object. This method handles the conversion to
456+
* avoid circular dependencies.
457+
*/
458+
private Citation convertToCitation(AnthropicApi.CitationResponse citationResponse) {
459+
return switch (citationResponse.type()) {
460+
case "char_location" -> Citation.ofCharLocation(citationResponse.citedText(),
461+
citationResponse.documentIndex(), citationResponse.documentTitle(),
462+
citationResponse.startCharIndex(), citationResponse.endCharIndex());
463+
case "page_location" -> Citation.ofPageLocation(citationResponse.citedText(),
464+
citationResponse.documentIndex(), citationResponse.documentTitle(),
465+
citationResponse.startPageNumber(), citationResponse.endPageNumber());
466+
case "content_block_location" -> Citation.ofContentBlockLocation(citationResponse.citedText(),
467+
citationResponse.documentIndex(), citationResponse.documentTitle(),
468+
citationResponse.startBlockIndex(), citationResponse.endBlockIndex());
469+
default -> throw new IllegalArgumentException("Unknown citation type: " + citationResponse.type());
470+
};
375471
}
376472

377473
private ChatResponseMetadata from(AnthropicApi.ChatCompletionResponse result) {
@@ -479,13 +575,22 @@ Prompt buildRequestPrompt(Prompt prompt) {
479575
// Merge cache options that are Json-ignored
480576
requestOptions.setCacheOptions(runtimeOptions.getCacheOptions() != null ? runtimeOptions.getCacheOptions()
481577
: this.defaultOptions.getCacheOptions());
578+
579+
// Merge citation documents that are Json-ignored
580+
if (runtimeOptions.getCitationDocuments() != null && !runtimeOptions.getCitationDocuments().isEmpty()) {
581+
requestOptions.setCitationDocuments(runtimeOptions.getCitationDocuments());
582+
}
583+
else if (this.defaultOptions.getCitationDocuments() != null) {
584+
requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments());
585+
}
482586
}
483587
else {
484588
requestOptions.setHttpHeaders(this.defaultOptions.getHttpHeaders());
485589
requestOptions.setInternalToolExecutionEnabled(this.defaultOptions.getInternalToolExecutionEnabled());
486590
requestOptions.setToolNames(this.defaultOptions.getToolNames());
487591
requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks());
488592
requestOptions.setToolContext(this.defaultOptions.getToolContext());
593+
requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments());
489594
}
490595

491596
ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks());
@@ -610,12 +715,24 @@ private List<AnthropicMessage> buildMessages(Prompt prompt, CacheEligibilityReso
610715
}
611716
}
612717

718+
// Get citation documents from options
719+
List<CitationDocument> citationDocuments = null;
720+
if (prompt.getOptions() instanceof AnthropicChatOptions anthropicOptions) {
721+
citationDocuments = anthropicOptions.getCitationDocuments();
722+
}
723+
613724
List<AnthropicMessage> result = new ArrayList<>();
614725
for (int i = 0; i < allMessages.size(); i++) {
615726
Message message = allMessages.get(i);
616727
MessageType messageType = message.getMessageType();
617728
if (messageType == MessageType.USER) {
618729
List<ContentBlock> contentBlocks = new ArrayList<>();
730+
// Add citation documents to the FIRST user message only
731+
if (i == 0 && citationDocuments != null && !citationDocuments.isEmpty()) {
732+
for (CitationDocument doc : citationDocuments) {
733+
contentBlocks.add(doc.toContentBlock());
734+
}
735+
}
619736
String content = message.getText();
620737
// For conversation history caching, apply cache control to the
621738
// last user message to cache the entire conversation up to that point.
@@ -825,4 +942,30 @@ public AnthropicChatModel build() {
825942

826943
}
827944

945+
/**
946+
* Context object for tracking citations during response processing. Aggregates
947+
* citations from multiple content blocks in a single response.
948+
*/
949+
class CitationContext {
950+
951+
private final List<Citation> allCitations = new ArrayList<>();
952+
953+
public void addCitations(List<Citation> citations) {
954+
this.allCitations.addAll(citations);
955+
}
956+
957+
public boolean hasCitations() {
958+
return !this.allCitations.isEmpty();
959+
}
960+
961+
public List<Citation> getAllCitations() {
962+
return new ArrayList<>(this.allCitations);
963+
}
964+
965+
public int getTotalCitationCount() {
966+
return this.allCitations.size();
967+
}
968+
969+
}
970+
828971
}

models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.springframework.ai.anthropic.api.AnthropicApi;
3434
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest;
3535
import org.springframework.ai.anthropic.api.AnthropicCacheOptions;
36+
import org.springframework.ai.anthropic.api.CitationDocument;
3637
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3738
import org.springframework.ai.tool.ToolCallback;
3839
import org.springframework.lang.Nullable;
@@ -63,6 +64,17 @@ public class AnthropicChatOptions implements ToolCallingChatOptions {
6364
private @JsonProperty("tool_choice") AnthropicApi.ToolChoice toolChoice;
6465
private @JsonProperty("thinking") ChatCompletionRequest.ThinkingConfig thinking;
6566

67+
/**
68+
* Documents to be used for citation-based responses. These documents will be
69+
* converted to ContentBlocks and included in the first user message of the request.
70+
* Citations indicating which parts of these documents were used in the response will
71+
* be returned in the response metadata under the "citations" key.
72+
* @see CitationDocument
73+
* @see Citation
74+
*/
75+
@JsonIgnore
76+
private List<CitationDocument> citationDocuments = new ArrayList<>();
77+
6678
@JsonIgnore
6779
private AnthropicCacheOptions cacheOptions = AnthropicCacheOptions.DISABLED;
6880

@@ -127,6 +139,8 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions)
127139
.toolContext(fromOptions.getToolContext() != null ? new HashMap<>(fromOptions.getToolContext()) : null)
128140
.httpHeaders(fromOptions.getHttpHeaders() != null ? new HashMap<>(fromOptions.getHttpHeaders()) : null)
129141
.cacheOptions(fromOptions.getCacheOptions())
142+
.citationDocuments(fromOptions.getCitationDocuments() != null
143+
? new ArrayList<>(fromOptions.getCitationDocuments()) : null)
130144
.build();
131145
}
132146

@@ -283,6 +297,34 @@ public void setHttpHeaders(Map<String, String> httpHeaders) {
283297
this.httpHeaders = httpHeaders;
284298
}
285299

300+
public List<CitationDocument> getCitationDocuments() {
301+
return this.citationDocuments;
302+
}
303+
304+
public void setCitationDocuments(List<CitationDocument> citationDocuments) {
305+
Assert.notNull(citationDocuments, "Citation documents cannot be null");
306+
this.citationDocuments = citationDocuments;
307+
}
308+
309+
/**
310+
* Validate that all citation documents have consistent citation settings. Anthropic
311+
* requires all documents to have citations enabled if any do.
312+
*/
313+
public void validateCitationConsistency() {
314+
if (this.citationDocuments.isEmpty()) {
315+
return;
316+
}
317+
318+
boolean hasEnabledCitations = this.citationDocuments.stream().anyMatch(CitationDocument::isCitationsEnabled);
319+
boolean hasDisabledCitations = this.citationDocuments.stream().anyMatch(doc -> !doc.isCitationsEnabled());
320+
321+
if (hasEnabledCitations && hasDisabledCitations) {
322+
throw new IllegalArgumentException(
323+
"Anthropic Citations API requires all documents to have consistent citation settings. "
324+
+ "Either enable citations for all documents or disable for all documents.");
325+
}
326+
}
327+
286328
@Override
287329
@SuppressWarnings("unchecked")
288330
public AnthropicChatOptions copy() {
@@ -308,14 +350,16 @@ public boolean equals(Object o) {
308350
&& Objects.equals(this.internalToolExecutionEnabled, that.internalToolExecutionEnabled)
309351
&& Objects.equals(this.toolContext, that.toolContext)
310352
&& Objects.equals(this.httpHeaders, that.httpHeaders)
311-
&& Objects.equals(this.cacheOptions, that.cacheOptions);
353+
&& Objects.equals(this.cacheOptions, that.cacheOptions)
354+
&& Objects.equals(this.citationDocuments, that.citationDocuments);
312355
}
313356

314357
@Override
315358
public int hashCode() {
316359
return Objects.hash(this.model, this.maxTokens, this.metadata, this.stopSequences, this.temperature, this.topP,
317360
this.topK, this.toolChoice, this.thinking, this.toolCallbacks, this.toolNames,
318-
this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions);
361+
this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions,
362+
this.citationDocuments);
319363
}
320364

321365
public static final class Builder {
@@ -425,7 +469,40 @@ public Builder cacheOptions(AnthropicCacheOptions cacheOptions) {
425469
return this;
426470
}
427471

472+
/**
473+
* Set citation documents for the request.
474+
* @param citationDocuments List of documents to include for citations
475+
* @return Builder for method chaining
476+
*/
477+
public Builder citationDocuments(List<CitationDocument> citationDocuments) {
478+
this.options.setCitationDocuments(citationDocuments);
479+
return this;
480+
}
481+
482+
/**
483+
* Set citation documents from variable arguments.
484+
* @param documents Variable number of CitationDocument objects
485+
* @return Builder for method chaining
486+
*/
487+
public Builder citationDocuments(CitationDocument... documents) {
488+
Assert.notNull(documents, "Citation documents cannot be null");
489+
this.options.citationDocuments.addAll(Arrays.asList(documents));
490+
return this;
491+
}
492+
493+
/**
494+
* Add a single citation document.
495+
* @param document Citation document to add
496+
* @return Builder for method chaining
497+
*/
498+
public Builder addCitationDocument(CitationDocument document) {
499+
Assert.notNull(document, "Citation document cannot be null");
500+
this.options.citationDocuments.add(document);
501+
return this;
502+
}
503+
428504
public AnthropicChatOptions build() {
505+
this.options.validateCitationConsistency();
429506
return this.options;
430507
}
431508

0 commit comments

Comments
 (0)