Skip to content

Commit bd90625

Browse files
sobychackomarkpollack
authored andcommitted
Add Claude Skills integration with Files API support
Integrate Anthropic's pre-built Skills for document generation. Skills enable Claude to create actual downloadable files rather than just describing them. Supported skills: - XLSX: Generate Excel spreadsheets - PPTX: Create PowerPoint presentations - DOCX: Generate Word documents - PDF: Create PDF files Example usage: AnthropicChatOptions.builder() .anthropicSkill(AnthropicSkill.XLSX) .build() Generated files can be downloaded via the Files API using SkillsResponseHelper to extract file IDs from responses. Breaking change: - ContentBlock.content type changed from String to Object to support nested JSON structures in Skills responses Signed-off-by: Soby Chacko <[email protected]>
1 parent 740a61c commit bd90625

File tree

14 files changed

+2919
-56
lines changed

14 files changed

+2919
-56
lines changed

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

Lines changed: 120 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatRespons
197197
() -> this.anthropicApi.chatCompletionEntity(request, this.getAdditionalHttpHeaders(prompt)));
198198

199199
AnthropicApi.ChatCompletionResponse completionResponse = completionEntity.getBody();
200+
200201
AnthropicApi.Usage usage = completionResponse.usage();
201202

202203
Usage currentChatResponseUsage = usage != null ? this.getDefaultUsage(completionResponse.usage())
@@ -381,7 +382,8 @@ private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion, Usage
381382
.usage(usage)
382383
.keyValue("stop-reason", chatCompletion.stopReason())
383384
.keyValue("stop-sequence", chatCompletion.stopSequence())
384-
.keyValue("type", chatCompletion.type());
385+
.keyValue("type", chatCompletion.type())
386+
.keyValue("anthropic-response", chatCompletion);
385387

386388
// Add citation metadata if citations were found
387389
if (citationContext.hasCitations()) {
@@ -584,6 +586,14 @@ Prompt buildRequestPrompt(Prompt prompt) {
584586
else if (this.defaultOptions.getCitationDocuments() != null) {
585587
requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments());
586588
}
589+
590+
// Merge skillContainer that is Json-ignored
591+
if (runtimeOptions.getSkillContainer() != null) {
592+
requestOptions.setSkillContainer(runtimeOptions.getSkillContainer());
593+
}
594+
else if (this.defaultOptions.getSkillContainer() != null) {
595+
requestOptions.setSkillContainer(this.defaultOptions.getSkillContainer());
596+
}
587597
}
588598
else {
589599
requestOptions.setHttpHeaders(this.defaultOptions.getHttpHeaders());
@@ -592,6 +602,7 @@ else if (this.defaultOptions.getCitationDocuments() != null) {
592602
requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks());
593603
requestOptions.setToolContext(this.defaultOptions.getToolContext());
594604
requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments());
605+
requestOptions.setSkillContainer(this.defaultOptions.getSkillContainer());
595606
}
596607

597608
ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks());
@@ -629,7 +640,16 @@ ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
629640
ChatCompletionRequest request = new ChatCompletionRequest(this.defaultOptions.getModel(), userMessages,
630641
systemContent, this.defaultOptions.getMaxTokens(), this.defaultOptions.getTemperature(), stream);
631642

632-
request = ModelOptionsUtils.merge(requestOptions, request, ChatCompletionRequest.class);
643+
// Save toolChoice for later application (after code_execution tool is added)
644+
AnthropicApi.ToolChoice savedToolChoice = requestOptions != null ? requestOptions.getToolChoice() : null;
645+
AnthropicChatOptions mergeOptions = requestOptions;
646+
if (savedToolChoice != null && requestOptions != null) {
647+
// Create a copy without toolChoice to avoid premature merge
648+
mergeOptions = requestOptions.copy();
649+
mergeOptions.setToolChoice(null);
650+
}
651+
652+
request = ModelOptionsUtils.merge(mergeOptions, request, ChatCompletionRequest.class);
633653

634654
// Add the tool definitions with potential caching
635655
List<ToolDefinition> toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions);
@@ -643,21 +663,112 @@ ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
643663
request = ChatCompletionRequest.from(request).tools(tools).build();
644664
}
645665

646-
// Add beta header for 1-hour TTL if needed
647-
if (cacheOptions.getMessageTypeTtl().containsValue(AnthropicCacheTtl.ONE_HOUR)) {
666+
// Add Skills container from options if present
667+
AnthropicApi.SkillContainer skillContainer = null;
668+
if (requestOptions != null && requestOptions.getSkillContainer() != null) {
669+
skillContainer = requestOptions.getSkillContainer();
670+
}
671+
else if (this.defaultOptions.getSkillContainer() != null) {
672+
skillContainer = this.defaultOptions.getSkillContainer();
673+
}
674+
675+
if (skillContainer != null) {
676+
request = ChatCompletionRequest.from(request).container(skillContainer).build();
677+
678+
// Skills require the code_execution tool to be enabled
679+
// Add it if not already present
680+
List<AnthropicApi.Tool> existingTools = request.tools() != null ? new ArrayList<>(request.tools())
681+
: new ArrayList<>();
682+
boolean hasCodeExecution = existingTools.stream().anyMatch(tool -> "code_execution".equals(tool.name()));
683+
684+
if (!hasCodeExecution) {
685+
existingTools
686+
.add(new AnthropicApi.Tool(AnthropicApi.CODE_EXECUTION_TOOL_TYPE, "code_execution", null, null));
687+
request = ChatCompletionRequest.from(request).tools(existingTools).build();
688+
}
689+
690+
// Apply saved toolChoice now that code_execution tool has been added
691+
if (savedToolChoice != null) {
692+
request = ChatCompletionRequest.from(request).toolChoice(savedToolChoice).build();
693+
}
694+
}
695+
else if (savedToolChoice != null) {
696+
// No Skills but toolChoice was set - apply it now
697+
request = ChatCompletionRequest.from(request).toolChoice(savedToolChoice).build();
698+
}
699+
700+
// Add beta headers if needed
701+
if (requestOptions != null) {
648702
Map<String, String> headers = new HashMap<>(requestOptions.getHttpHeaders());
649-
headers.put("anthropic-beta", AnthropicApi.BETA_EXTENDED_CACHE_TTL);
650-
requestOptions.setHttpHeaders(headers);
703+
boolean needsUpdate = false;
704+
705+
// Add Skills beta headers if Skills are present
706+
// Skills require three beta headers: skills, code-execution, and files-api
707+
if (skillContainer != null) {
708+
String existingBeta = headers.get("anthropic-beta");
709+
String requiredBetas = AnthropicApi.BETA_SKILLS + "," + AnthropicApi.BETA_CODE_EXECUTION + ","
710+
+ AnthropicApi.BETA_FILES_API;
711+
712+
if (existingBeta != null) {
713+
// Add missing beta headers
714+
if (!existingBeta.contains(AnthropicApi.BETA_SKILLS)) {
715+
existingBeta = existingBeta + "," + AnthropicApi.BETA_SKILLS;
716+
}
717+
if (!existingBeta.contains(AnthropicApi.BETA_CODE_EXECUTION)) {
718+
existingBeta = existingBeta + "," + AnthropicApi.BETA_CODE_EXECUTION;
719+
}
720+
if (!existingBeta.contains(AnthropicApi.BETA_FILES_API)) {
721+
existingBeta = existingBeta + "," + AnthropicApi.BETA_FILES_API;
722+
}
723+
headers.put("anthropic-beta", existingBeta);
724+
}
725+
else {
726+
headers.put("anthropic-beta", requiredBetas);
727+
}
728+
needsUpdate = true;
729+
}
730+
731+
// Add extended cache TTL beta header if needed
732+
if (cacheOptions.getMessageTypeTtl().containsValue(AnthropicCacheTtl.ONE_HOUR)) {
733+
String existingBeta = headers.get("anthropic-beta");
734+
if (existingBeta != null && !existingBeta.contains(AnthropicApi.BETA_EXTENDED_CACHE_TTL)) {
735+
headers.put("anthropic-beta", existingBeta + "," + AnthropicApi.BETA_EXTENDED_CACHE_TTL);
736+
}
737+
else if (existingBeta == null) {
738+
headers.put("anthropic-beta", AnthropicApi.BETA_EXTENDED_CACHE_TTL);
739+
}
740+
needsUpdate = true;
741+
}
742+
743+
if (needsUpdate) {
744+
requestOptions.setHttpHeaders(headers);
745+
}
651746
}
652747

653748
return request;
654749
}
655750

751+
/**
752+
* Helper method to serialize content from ContentBlock. The content field can be
753+
* either a String or a complex object (for Skills responses).
754+
* @param content The content to serialize
755+
* @return String representation of the content, or null if content is null
756+
*/
757+
private static String serializeContent(Object content) {
758+
if (content == null) {
759+
return null;
760+
}
761+
if (content instanceof String s) {
762+
return s;
763+
}
764+
return JsonParser.toJson(content);
765+
}
766+
656767
private static ContentBlock cacheAwareContentBlock(ContentBlock contentBlock, MessageType messageType,
657768
CacheEligibilityResolver cacheEligibilityResolver) {
658769
String basisForLength = switch (contentBlock.type()) {
659770
case TEXT, TEXT_DELTA -> contentBlock.text();
660-
case TOOL_RESULT -> contentBlock.content();
771+
case TOOL_RESULT -> serializeContent(contentBlock.content());
661772
case TOOL_USE -> JsonParser.toJson(contentBlock.input());
662773
case THINKING, THINKING_DELTA -> contentBlock.thinking();
663774
case REDACTED_THINKING -> contentBlock.data();
@@ -846,7 +957,8 @@ private List<AnthropicApi.Tool> addCacheToLastTool(List<AnthropicApi.Tool> tools
846957
AnthropicApi.Tool tool = tools.get(i);
847958
if (i == tools.size() - 1) {
848959
// Add cache control to last tool
849-
tool = new AnthropicApi.Tool(tool.name(), tool.description(), tool.inputSchema(), cacheControl);
960+
tool = new AnthropicApi.Tool(tool.type(), tool.name(), tool.description(), tool.inputSchema(),
961+
cacheControl);
850962
cacheEligibilityResolver.useCacheBlock();
851963
}
852964
modifiedTools.add(tool);

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

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ public AnthropicCacheOptions getCacheOptions() {
8888
public void setCacheOptions(AnthropicCacheOptions cacheOptions) {
8989
this.cacheOptions = cacheOptions;
9090
}
91+
92+
/**
93+
* Container for Claude Skills to make available in this request.
94+
* Skills are collections of instructions, scripts, and resources that
95+
* extend Claude's capabilities for specific domains.
96+
* Maximum of 8 skills per request.
97+
*/
98+
@JsonIgnore
99+
private AnthropicApi.SkillContainer skillContainer;
100+
101+
public AnthropicApi.SkillContainer getSkillContainer() {
102+
return this.skillContainer;
103+
}
104+
105+
public void setSkillContainer(AnthropicApi.SkillContainer skillContainer) {
106+
this.skillContainer = skillContainer;
107+
}
108+
91109
/**
92110
* Collection of {@link ToolCallback}s to be used for tool calling in the chat
93111
* completion requests.
@@ -150,6 +168,7 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions)
150168
.citationDocuments(fromOptions.getCitationDocuments() != null
151169
? new ArrayList<>(fromOptions.getCitationDocuments()) : null)
152170
.outputFormat(fromOptions.getOutputFormat())
171+
.skillContainer(fromOptions.getSkillContainer())
153172
.build();
154173
}
155174

@@ -382,15 +401,16 @@ public boolean equals(Object o) {
382401
&& Objects.equals(this.httpHeaders, that.httpHeaders)
383402
&& Objects.equals(this.cacheOptions, that.cacheOptions)
384403
&& Objects.equals(this.outputFormat, that.outputFormat)
385-
&& Objects.equals(this.citationDocuments, that.citationDocuments);
404+
&& Objects.equals(this.citationDocuments, that.citationDocuments)
405+
&& Objects.equals(this.skillContainer, that.skillContainer);
386406
}
387407

388408
@Override
389409
public int hashCode() {
390410
return Objects.hash(this.model, this.maxTokens, this.metadata, this.stopSequences, this.temperature, this.topP,
391411
this.topK, this.toolChoice, this.thinking, this.toolCallbacks, this.toolNames,
392412
this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions,
393-
this.outputFormat, this.citationDocuments);
413+
this.outputFormat, this.citationDocuments, this.skillContainer);
394414
}
395415

396416
public static final class Builder {
@@ -542,11 +562,94 @@ public Builder outputSchema(String outputSchema) {
542562
return this;
543563
}
544564

565+
/**
566+
* Set the Skills container for this request.
567+
* @param skillContainer Container with skills to make available
568+
* @return Builder for method chaining
569+
*/
570+
public Builder skillContainer(AnthropicApi.SkillContainer skillContainer) {
571+
this.options.setSkillContainer(skillContainer);
572+
return this;
573+
}
574+
575+
/**
576+
* Add a single skill to the request. Creates a SkillContainer if one doesn't
577+
* exist.
578+
* @param skill Skill to add
579+
* @return Builder for method chaining
580+
*/
581+
public Builder skill(AnthropicApi.Skill skill) {
582+
Assert.notNull(skill, "Skill cannot be null");
583+
if (this.options.skillContainer == null) {
584+
this.options.skillContainer = AnthropicApi.SkillContainer.builder().skill(skill).build();
585+
}
586+
else {
587+
// Rebuild container with additional skill
588+
List<AnthropicApi.Skill> existingSkills = new ArrayList<>(this.options.skillContainer.skills());
589+
existingSkills.add(skill);
590+
this.options.skillContainer = new AnthropicApi.SkillContainer(existingSkills);
591+
}
592+
return this;
593+
}
594+
595+
/**
596+
* Add an Anthropic pre-built skill (xlsx, pptx, docx, pdf).
597+
*
598+
* <p>
599+
* Example: <pre>{@code
600+
* AnthropicChatOptions options = AnthropicChatOptions.builder()
601+
* .model("claude-sonnet-4-5")
602+
* .anthropicSkill(AnthropicSkill.XLSX)
603+
* .anthropicSkill(AnthropicSkill.PPTX)
604+
* .build();
605+
* }</pre>
606+
* @param anthropicSkill Pre-built Anthropic skill to add
607+
* @return Builder for method chaining
608+
*/
609+
public Builder anthropicSkill(AnthropicApi.AnthropicSkill anthropicSkill) {
610+
Assert.notNull(anthropicSkill, "AnthropicSkill cannot be null");
611+
return skill(anthropicSkill.toSkill());
612+
}
613+
614+
/**
615+
* Add an Anthropic pre-built skill with specific version.
616+
* @param anthropicSkill Pre-built Anthropic skill to add
617+
* @param version Version of the skill (e.g., "latest", "20251013")
618+
* @return Builder for method chaining
619+
*/
620+
public Builder anthropicSkill(AnthropicApi.AnthropicSkill anthropicSkill, String version) {
621+
Assert.notNull(anthropicSkill, "AnthropicSkill cannot be null");
622+
Assert.hasText(version, "Version cannot be empty");
623+
return skill(anthropicSkill.toSkill(version));
624+
}
625+
626+
/**
627+
* Add a custom skill by ID.
628+
* @param skillId Custom skill ID
629+
* @return Builder for method chaining
630+
*/
631+
public Builder customSkill(String skillId) {
632+
Assert.hasText(skillId, "Skill ID cannot be empty");
633+
return skill(new AnthropicApi.Skill(AnthropicApi.SkillType.CUSTOM, skillId));
634+
}
635+
636+
/**
637+
* Add a custom skill with specific version.
638+
* @param skillId Custom skill ID
639+
* @param version Version of the skill
640+
* @return Builder for method chaining
641+
*/
642+
public Builder customSkill(String skillId, String version) {
643+
Assert.hasText(skillId, "Skill ID cannot be empty");
644+
Assert.hasText(version, "Version cannot be empty");
645+
return skill(new AnthropicApi.Skill(AnthropicApi.SkillType.CUSTOM, skillId, version));
646+
}
647+
545648
public AnthropicChatOptions build() {
546649
this.options.validateCitationConsistency();
547650
return this.options;
548651
}
549652

550653
}
551654

552-
}
655+
}

0 commit comments

Comments
 (0)