diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java index ef965165d08..57f38987b12 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java @@ -197,6 +197,7 @@ public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatRespons ctx -> this.anthropicApi.chatCompletionEntity(request, this.getAdditionalHttpHeaders(prompt))); AnthropicApi.ChatCompletionResponse completionResponse = completionEntity.getBody(); + AnthropicApi.Usage usage = completionResponse.usage(); Usage currentChatResponseUsage = usage != null ? this.getDefaultUsage(completionResponse.usage()) @@ -381,7 +382,8 @@ private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion, Usage .usage(usage) .keyValue("stop-reason", chatCompletion.stopReason()) .keyValue("stop-sequence", chatCompletion.stopSequence()) - .keyValue("type", chatCompletion.type()); + .keyValue("type", chatCompletion.type()) + .keyValue("anthropic-response", chatCompletion); // Add citation metadata if citations were found if (citationContext.hasCitations()) { @@ -583,6 +585,14 @@ Prompt buildRequestPrompt(Prompt prompt) { else if (this.defaultOptions.getCitationDocuments() != null) { requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments()); } + + // Merge skillContainer that is Json-ignored + if (runtimeOptions.getSkillContainer() != null) { + requestOptions.setSkillContainer(runtimeOptions.getSkillContainer()); + } + else if (this.defaultOptions.getSkillContainer() != null) { + requestOptions.setSkillContainer(this.defaultOptions.getSkillContainer()); + } } else { requestOptions.setHttpHeaders(this.defaultOptions.getHttpHeaders()); @@ -591,6 +601,7 @@ else if (this.defaultOptions.getCitationDocuments() != null) { requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks()); requestOptions.setToolContext(this.defaultOptions.getToolContext()); requestOptions.setCitationDocuments(this.defaultOptions.getCitationDocuments()); + requestOptions.setSkillContainer(this.defaultOptions.getSkillContainer()); } ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks()); @@ -628,7 +639,16 @@ ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { ChatCompletionRequest request = new ChatCompletionRequest(this.defaultOptions.getModel(), userMessages, systemContent, this.defaultOptions.getMaxTokens(), this.defaultOptions.getTemperature(), stream); - request = ModelOptionsUtils.merge(requestOptions, request, ChatCompletionRequest.class); + // Save toolChoice for later application (after code_execution tool is added) + AnthropicApi.ToolChoice savedToolChoice = requestOptions != null ? requestOptions.getToolChoice() : null; + AnthropicChatOptions mergeOptions = requestOptions; + if (savedToolChoice != null && requestOptions != null) { + // Create a copy without toolChoice to avoid premature merge + mergeOptions = requestOptions.copy(); + mergeOptions.setToolChoice(null); + } + + request = ModelOptionsUtils.merge(mergeOptions, request, ChatCompletionRequest.class); // Add the tool definitions with potential caching List toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions); @@ -642,21 +662,112 @@ ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { request = ChatCompletionRequest.from(request).tools(tools).build(); } - // Add beta header for 1-hour TTL if needed - if (cacheOptions.getMessageTypeTtl().containsValue(AnthropicCacheTtl.ONE_HOUR)) { + // Add Skills container from options if present + AnthropicApi.SkillContainer skillContainer = null; + if (requestOptions != null && requestOptions.getSkillContainer() != null) { + skillContainer = requestOptions.getSkillContainer(); + } + else if (this.defaultOptions.getSkillContainer() != null) { + skillContainer = this.defaultOptions.getSkillContainer(); + } + + if (skillContainer != null) { + request = ChatCompletionRequest.from(request).container(skillContainer).build(); + + // Skills require the code_execution tool to be enabled + // Add it if not already present + List existingTools = request.tools() != null ? new ArrayList<>(request.tools()) + : new ArrayList<>(); + boolean hasCodeExecution = existingTools.stream().anyMatch(tool -> "code_execution".equals(tool.name())); + + if (!hasCodeExecution) { + existingTools + .add(new AnthropicApi.Tool(AnthropicApi.CODE_EXECUTION_TOOL_TYPE, "code_execution", null, null)); + request = ChatCompletionRequest.from(request).tools(existingTools).build(); + } + + // Apply saved toolChoice now that code_execution tool has been added + if (savedToolChoice != null) { + request = ChatCompletionRequest.from(request).toolChoice(savedToolChoice).build(); + } + } + else if (savedToolChoice != null) { + // No Skills but toolChoice was set - apply it now + request = ChatCompletionRequest.from(request).toolChoice(savedToolChoice).build(); + } + + // Add beta headers if needed + if (requestOptions != null) { Map headers = new HashMap<>(requestOptions.getHttpHeaders()); - headers.put("anthropic-beta", AnthropicApi.BETA_EXTENDED_CACHE_TTL); - requestOptions.setHttpHeaders(headers); + boolean needsUpdate = false; + + // Add Skills beta headers if Skills are present + // Skills require three beta headers: skills, code-execution, and files-api + if (skillContainer != null) { + String existingBeta = headers.get("anthropic-beta"); + String requiredBetas = AnthropicApi.BETA_SKILLS + "," + AnthropicApi.BETA_CODE_EXECUTION + "," + + AnthropicApi.BETA_FILES_API; + + if (existingBeta != null) { + // Add missing beta headers + if (!existingBeta.contains(AnthropicApi.BETA_SKILLS)) { + existingBeta = existingBeta + "," + AnthropicApi.BETA_SKILLS; + } + if (!existingBeta.contains(AnthropicApi.BETA_CODE_EXECUTION)) { + existingBeta = existingBeta + "," + AnthropicApi.BETA_CODE_EXECUTION; + } + if (!existingBeta.contains(AnthropicApi.BETA_FILES_API)) { + existingBeta = existingBeta + "," + AnthropicApi.BETA_FILES_API; + } + headers.put("anthropic-beta", existingBeta); + } + else { + headers.put("anthropic-beta", requiredBetas); + } + needsUpdate = true; + } + + // Add extended cache TTL beta header if needed + if (cacheOptions.getMessageTypeTtl().containsValue(AnthropicCacheTtl.ONE_HOUR)) { + String existingBeta = headers.get("anthropic-beta"); + if (existingBeta != null && !existingBeta.contains(AnthropicApi.BETA_EXTENDED_CACHE_TTL)) { + headers.put("anthropic-beta", existingBeta + "," + AnthropicApi.BETA_EXTENDED_CACHE_TTL); + } + else if (existingBeta == null) { + headers.put("anthropic-beta", AnthropicApi.BETA_EXTENDED_CACHE_TTL); + } + needsUpdate = true; + } + + if (needsUpdate) { + requestOptions.setHttpHeaders(headers); + } } return request; } + /** + * Helper method to serialize content from ContentBlock. The content field can be + * either a String or a complex object (for Skills responses). + * @param content The content to serialize + * @return String representation of the content, or null if content is null + */ + private static String serializeContent(Object content) { + if (content == null) { + return null; + } + if (content instanceof String s) { + return s; + } + return JsonParser.toJson(content); + } + private static ContentBlock cacheAwareContentBlock(ContentBlock contentBlock, MessageType messageType, CacheEligibilityResolver cacheEligibilityResolver) { String basisForLength = switch (contentBlock.type()) { case TEXT, TEXT_DELTA -> contentBlock.text(); - case TOOL_RESULT -> contentBlock.content(); + case TOOL_RESULT -> serializeContent(contentBlock.content()); case TOOL_USE -> JsonParser.toJson(contentBlock.input()); case THINKING, THINKING_DELTA -> contentBlock.thinking(); case REDACTED_THINKING -> contentBlock.data(); @@ -845,7 +956,8 @@ private List addCacheToLastTool(List tools AnthropicApi.Tool tool = tools.get(i); if (i == tools.size() - 1) { // Add cache control to last tool - tool = new AnthropicApi.Tool(tool.name(), tool.description(), tool.inputSchema(), cacheControl); + tool = new AnthropicApi.Tool(tool.type(), tool.name(), tool.description(), tool.inputSchema(), + cacheControl); cacheEligibilityResolver.useCacheBlock(); } modifiedTools.add(tool); diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java index 8061bb71e42..fb9e66d5c71 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java @@ -85,6 +85,24 @@ public AnthropicCacheOptions getCacheOptions() { public void setCacheOptions(AnthropicCacheOptions cacheOptions) { this.cacheOptions = cacheOptions; } + + /** + * Container for Claude Skills to make available in this request. + * Skills are collections of instructions, scripts, and resources that + * extend Claude's capabilities for specific domains. + * Maximum of 8 skills per request. + */ + @JsonIgnore + private AnthropicApi.SkillContainer skillContainer; + + public AnthropicApi.SkillContainer getSkillContainer() { + return this.skillContainer; + } + + public void setSkillContainer(AnthropicApi.SkillContainer skillContainer) { + this.skillContainer = skillContainer; + } + /** * Collection of {@link ToolCallback}s to be used for tool calling in the chat * completion requests. @@ -141,6 +159,7 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions) .cacheOptions(fromOptions.getCacheOptions()) .citationDocuments(fromOptions.getCitationDocuments() != null ? new ArrayList<>(fromOptions.getCitationDocuments()) : null) + .skillContainer(fromOptions.getSkillContainer()) .build(); } @@ -351,7 +370,8 @@ public boolean equals(Object o) { && Objects.equals(this.toolContext, that.toolContext) && Objects.equals(this.httpHeaders, that.httpHeaders) && Objects.equals(this.cacheOptions, that.cacheOptions) - && Objects.equals(this.citationDocuments, that.citationDocuments); + && Objects.equals(this.citationDocuments, that.citationDocuments) + && Objects.equals(this.skillContainer, that.skillContainer); } @Override @@ -359,7 +379,7 @@ public int hashCode() { return Objects.hash(this.model, this.maxTokens, this.metadata, this.stopSequences, this.temperature, this.topP, this.topK, this.toolChoice, this.thinking, this.toolCallbacks, this.toolNames, this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions, - this.citationDocuments); + this.citationDocuments, this.skillContainer); } public static final class Builder { @@ -501,6 +521,89 @@ public Builder addCitationDocument(CitationDocument document) { return this; } + /** + * Set the Skills container for this request. + * @param skillContainer Container with skills to make available + * @return Builder for method chaining + */ + public Builder skillContainer(AnthropicApi.SkillContainer skillContainer) { + this.options.setSkillContainer(skillContainer); + return this; + } + + /** + * Add a single skill to the request. Creates a SkillContainer if one doesn't + * exist. + * @param skill Skill to add + * @return Builder for method chaining + */ + public Builder skill(AnthropicApi.Skill skill) { + Assert.notNull(skill, "Skill cannot be null"); + if (this.options.skillContainer == null) { + this.options.skillContainer = AnthropicApi.SkillContainer.builder().skill(skill).build(); + } + else { + // Rebuild container with additional skill + List existingSkills = new ArrayList<>(this.options.skillContainer.skills()); + existingSkills.add(skill); + this.options.skillContainer = new AnthropicApi.SkillContainer(existingSkills); + } + return this; + } + + /** + * Add an Anthropic pre-built skill (xlsx, pptx, docx, pdf). + * + *

+ * Example:

{@code
+		 * AnthropicChatOptions options = AnthropicChatOptions.builder()
+		 *     .model("claude-sonnet-4-5")
+		 *     .anthropicSkill(AnthropicSkill.XLSX)
+		 *     .anthropicSkill(AnthropicSkill.PPTX)
+		 *     .build();
+		 * }
+ * @param anthropicSkill Pre-built Anthropic skill to add + * @return Builder for method chaining + */ + public Builder anthropicSkill(AnthropicApi.AnthropicSkill anthropicSkill) { + Assert.notNull(anthropicSkill, "AnthropicSkill cannot be null"); + return skill(anthropicSkill.toSkill()); + } + + /** + * Add an Anthropic pre-built skill with specific version. + * @param anthropicSkill Pre-built Anthropic skill to add + * @param version Version of the skill (e.g., "latest", "20251013") + * @return Builder for method chaining + */ + public Builder anthropicSkill(AnthropicApi.AnthropicSkill anthropicSkill, String version) { + Assert.notNull(anthropicSkill, "AnthropicSkill cannot be null"); + Assert.hasText(version, "Version cannot be empty"); + return skill(anthropicSkill.toSkill(version)); + } + + /** + * Add a custom skill by ID. + * @param skillId Custom skill ID + * @return Builder for method chaining + */ + public Builder customSkill(String skillId) { + Assert.hasText(skillId, "Skill ID cannot be empty"); + return skill(new AnthropicApi.Skill(AnthropicApi.SkillType.CUSTOM, skillId)); + } + + /** + * Add a custom skill with specific version. + * @param skillId Custom skill ID + * @param version Version of the skill + * @return Builder for method chaining + */ + public Builder customSkill(String skillId, String version) { + Assert.hasText(skillId, "Skill ID cannot be empty"); + Assert.hasText(version, "Version cannot be empty"); + return skill(new AnthropicApi.Skill(AnthropicApi.SkillType.CUSTOM, skillId, version)); + } + public AnthropicChatOptions build() { this.options.validateCitationConsistency(); return this.options; diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/SkillsResponseHelper.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/SkillsResponseHelper.java new file mode 100644 index 00000000000..bba2a20746a --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/SkillsResponseHelper.java @@ -0,0 +1,191 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock; +import org.springframework.ai.anthropic.api.AnthropicApi.FileMetadata; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.util.Assert; + +/** + * Helper utilities for working with Claude Skills responses and files. Provides methods + * to extract file IDs, container IDs, and download files generated by Skills. + * + * @author Soby Chacko + * @since 2.0.0 + */ +public final class SkillsResponseHelper { + + private SkillsResponseHelper() { + // Utility class, no instantiation + } + + /** + * Extract all file IDs from a chat response. Searches through all content blocks in + * the response, including those in the underlying AnthropicApi response metadata. + * @param response The chat response to search + * @return List of file IDs found in the response (empty list if none found) + */ + public static List extractFileIds(ChatResponse response) { + if (response == null) { + return List.of(); + } + + List fileIds = new ArrayList<>(); + + // Try to get the underlying Anthropic response from ChatResponse metadata + if (response.getMetadata() != null) { + Object anthropicResponse = response.getMetadata().get("anthropic-response"); + if (anthropicResponse instanceof ChatCompletionResponse chatCompletionResponse) { + fileIds.addAll(extractFileIdsFromContentBlocks(chatCompletionResponse.content())); + } + } + + return fileIds; + } + + /** + * Extract container ID from a chat response for multi-turn conversation reuse. + * @param response The chat response + * @return Container ID if present, null otherwise + */ + public static String extractContainerId(ChatResponse response) { + if (response == null) { + return null; + } + + // Try to get container from ChatResponse metadata + if (response.getMetadata() != null) { + Object anthropicResponse = response.getMetadata().get("anthropic-response"); + if (anthropicResponse instanceof ChatCompletionResponse chatCompletionResponse) { + if (chatCompletionResponse.container() != null) { + return chatCompletionResponse.container().id(); + } + } + } + + return null; + } + + /** + * Download all files from a Skills response to a target directory. + * + *

+ * Note: Existing files with the same name will be overwritten. Check for file + * existence before calling if overwrite protection is needed. + * @param response The chat response containing file IDs + * @param api The Anthropic API client to use for downloading + * @param targetDir Directory to save files (must exist) + * @return List of paths to saved files + * @throws IOException if file download or saving fails + */ + public static List downloadAllFiles(ChatResponse response, AnthropicApi api, Path targetDir) + throws IOException { + Assert.notNull(response, "Response cannot be null"); + Assert.notNull(api, "AnthropicApi cannot be null"); + Assert.notNull(targetDir, "Target directory cannot be null"); + Assert.isTrue(Files.isDirectory(targetDir), "Target path must be a directory"); + + List fileIds = extractFileIds(response); + List savedPaths = new ArrayList<>(); + + for (String fileId : fileIds) { + // Get metadata for filename + FileMetadata metadata = api.getFileMetadata(fileId); + + // Download file + byte[] content = api.downloadFile(fileId); + + // Save to target directory + Path filePath = targetDir.resolve(metadata.filename()); + Files.write(filePath, content); + savedPaths.add(filePath); + } + + return savedPaths; + } + + /** + * Extract file IDs from a list of content blocks. Searches both direct file blocks + * and nested content structures (for Skills tool results). + * @param contentBlocks List of content blocks to search + * @return List of file IDs found + */ + private static List extractFileIdsFromContentBlocks(List contentBlocks) { + if (contentBlocks == null || contentBlocks.isEmpty()) { + return List.of(); + } + + List fileIds = new ArrayList<>(); + + for (ContentBlock block : contentBlocks) { + // Check direct fileId field (top-level file blocks) + if (block.type() == ContentBlock.Type.FILE && block.fileId() != null) { + fileIds.add(block.fileId()); + } + + // Check nested content field (Skills tool results with complex JSON + // structures) + if (block.content() != null) { + fileIds.addAll(extractFileIdsFromObject(block.content())); + } + } + + return fileIds; + } + + /** + * Recursively extract file IDs from any object structure. Handles nested Maps and + * Lists that may contain file_id fields deep in the structure (e.g., Skills + * bash_code_execution_tool_result responses). + * @param obj The object to search (can be Map, List, String, or other types) + * @return List of file IDs found in the object structure + */ + private static List extractFileIdsFromObject(Object obj) { + List fileIds = new ArrayList<>(); + + if (obj instanceof Map map) { + // Check if this map has a file_id key + if (map.containsKey("file_id") && map.get("file_id") instanceof String fileId) { + fileIds.add(fileId); + } + // Recursively search all values in the map + for (Object value : map.values()) { + fileIds.addAll(extractFileIdsFromObject(value)); + } + } + else if (obj instanceof List list) { + // Recursively search all list items + for (Object item : list) { + fileIds.addAll(extractFileIdsFromObject(item)); + } + } + // For String, Number, etc., there are no file_ids to extract + + return fileIds; + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java index 497d43acc4a..6457c6112b9 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java @@ -84,12 +84,22 @@ public static Builder builder() { public static final String DEFAULT_MESSAGE_COMPLETIONS_PATH = "/v1/messages"; + public static final String FILES_PATH = "/v1/files"; + public static final String DEFAULT_ANTHROPIC_VERSION = "2023-06-01"; public static final String DEFAULT_ANTHROPIC_BETA_VERSION = "tools-2024-04-04,pdfs-2024-09-25"; public static final String BETA_EXTENDED_CACHE_TTL = "extended-cache-ttl-2025-04-11"; + public static final String BETA_SKILLS = "skills-2025-10-02"; + + public static final String BETA_FILES_API = "files-api-2025-04-14"; + + public static final String BETA_CODE_EXECUTION = "code-execution-2025-08-25"; + + public static final String CODE_EXECUTION_TOOL_TYPE = "code_execution_20250825"; + private static final String HEADER_X_API_KEY = "x-api-key"; private static final String HEADER_ANTHROPIC_VERSION = "anthropic-version"; @@ -255,6 +265,97 @@ public Flux chatCompletionStream(ChatCompletionRequest c .filter(chatCompletionResponse -> chatCompletionResponse.type() != null); } + // ------------------------------------------------------------------------ + // Files API Methods + // ------------------------------------------------------------------------ + + /** + * Get metadata for a specific file generated by Skills or uploaded via Files API. + * @param fileId The file ID to retrieve (format: file_*) + * @return File metadata including filename, size, mime type, and expiration + */ + public FileMetadata getFileMetadata(String fileId) { + Assert.hasText(fileId, "File ID cannot be empty"); + + return this.restClient.get() + .uri(FILES_PATH + "/{id}", fileId) + .headers(headers -> { + addDefaultHeadersIfMissing(headers); + // Append files-api beta to existing beta headers if not already present + String existingBeta = headers.getFirst(HEADER_ANTHROPIC_BETA); + if (existingBeta != null && !existingBeta.contains(BETA_FILES_API)) { + headers.set(HEADER_ANTHROPIC_BETA, existingBeta + "," + BETA_FILES_API); + } + else if (existingBeta == null) { + headers.set(HEADER_ANTHROPIC_BETA, BETA_FILES_API); + } + }) + .retrieve() + .body(FileMetadata.class); + } + + /** + * Download file content as byte array. Suitable for small to medium files. + * @param fileId The file ID to download + * @return File content as bytes + */ + public byte[] downloadFile(String fileId) { + Assert.hasText(fileId, "File ID cannot be empty"); + + return this.restClient.get() + .uri(FILES_PATH + "/{id}/content", fileId) + .headers(headers -> { + addDefaultHeadersIfMissing(headers); + // Append files-api beta to existing beta headers if not already present + String existingBeta = headers.getFirst(HEADER_ANTHROPIC_BETA); + if (existingBeta != null && !existingBeta.contains(BETA_FILES_API)) { + headers.set(HEADER_ANTHROPIC_BETA, existingBeta + "," + BETA_FILES_API); + } + else if (existingBeta == null) { + headers.set(HEADER_ANTHROPIC_BETA, BETA_FILES_API); + } + }) + .retrieve() + .body(byte[].class); + } + + /** + * List all files with optional pagination. + * @param limit Maximum number of results per page (default 20, max 100) + * @param page Pagination token from previous response + * @return Paginated list of files + */ + public FilesListResponse listFiles(Integer limit, String page) { + return this.restClient.get() + .uri(uriBuilder -> { + uriBuilder.path(FILES_PATH); + if (limit != null) { + uriBuilder.queryParam("limit", limit); + } + if (page != null) { + uriBuilder.queryParam("page", page); + } + return uriBuilder.build(); + }) + .retrieve() + .body(FilesListResponse.class); + } + + /** + * Delete a file. Files expire automatically after 24 hours, but this allows + * immediate cleanup. + * @param fileId The file ID to delete + */ + public void deleteFile(String fileId) { + Assert.hasText(fileId, "File ID cannot be empty"); + + this.restClient.delete().uri(FILES_PATH + "/{id}", fileId).retrieve().toBodilessEntity(); + } + + // ------------------------------------------------------------------------ + // Private Helper Methods + // ------------------------------------------------------------------------ + private void addDefaultHeadersIfMissing(HttpHeaders headers) { if (!headers.containsKey(HEADER_X_API_KEY)) { String apiKeyValue = this.apiKey.getValue(); @@ -390,6 +491,266 @@ public enum ThinkingType { } + /** + * Types of Claude Skills. + */ + public enum SkillType { + + /** + * Pre-built skills provided by Anthropic (xlsx, pptx, docx, pdf). + */ + @JsonProperty("anthropic") + ANTHROPIC("anthropic"), + + /** + * Custom skills uploaded to the workspace. + */ + @JsonProperty("custom") + CUSTOM("custom"); + + private final String value; + + SkillType(String value) { + this.value = value; + } + + public String getValue() { + return this.value; + } + + } + + /** + * Pre-built Anthropic Skills for document generation. + */ + public enum AnthropicSkill { + + // @formatter:off + /** + * Excel spreadsheet generation and manipulation. + */ + XLSX("xlsx", "Excel spreadsheet generation"), + + /** + * PowerPoint presentation creation. + */ + PPTX("pptx", "PowerPoint presentation creation"), + + /** + * Word document generation. + */ + DOCX("docx", "Word document generation"), + + /** + * PDF document creation. + */ + PDF("pdf", "PDF document creation"); + // @formatter:on + + private final String skillId; + + private final String description; + + AnthropicSkill(String skillId, String description) { + this.skillId = skillId; + this.description = description; + } + + public String getSkillId() { + return this.skillId; + } + + public String getDescription() { + return this.description; + } + + /** + * Convert to a Skill record with latest version. + * @return Skill record + */ + public Skill toSkill() { + return new Skill(SkillType.ANTHROPIC, this.skillId, "latest"); + } + + /** + * Convert to a Skill record with specific version. + * @param version Version string + * @return Skill record + */ + public Skill toSkill(String version) { + return new Skill(SkillType.ANTHROPIC, this.skillId, version); + } + + } + + /** + * Represents a Claude Skill - either pre-built Anthropic skill or custom skill. + * Skills are collections of instructions, scripts, and resources that extend Claude's + * capabilities for specific domains. + * + * @param type The skill type: "anthropic" for pre-built skills, "custom" for uploaded + * skills + * @param skillId Skill identifier - short name for Anthropic skills (e.g., "xlsx", + * "pptx"), generated ID for custom skills + * @param version Optional version - "latest", date-based (e.g., "20251013"), or epoch + * timestamp + */ + @JsonInclude(Include.NON_NULL) + public record Skill(@JsonProperty("type") SkillType type, @JsonProperty("skill_id") String skillId, + @JsonProperty("version") String version) { + + /** + * Create a Skill with default "latest" version. + * @param type Skill type + * @param skillId Skill ID + */ + public Skill(SkillType type, String skillId) { + this(type, skillId, "latest"); + } + + public static SkillBuilder builder() { + return new SkillBuilder(); + } + + public static final class SkillBuilder { + + private SkillType type; + + private String skillId; + + private String version = "latest"; + + public SkillBuilder type(SkillType type) { + this.type = type; + return this; + } + + public SkillBuilder skillId(String skillId) { + this.skillId = skillId; + return this; + } + + public SkillBuilder version(String version) { + this.version = version; + return this; + } + + public Skill build() { + Assert.notNull(this.type, "Skill type cannot be null"); + Assert.hasText(this.skillId, "Skill ID cannot be empty"); + return new Skill(this.type, this.skillId, this.version); + } + + } + + } + + /** + * Container for Claude Skills in a chat completion request. Maximum of 8 skills per + * request. + * + * @param skills List of skills to make available to Claude + */ + @JsonInclude(Include.NON_NULL) + public record SkillContainer(@JsonProperty("skills") List skills) { + + public SkillContainer { + Assert.notNull(skills, "Skills list cannot be null"); + Assert.notEmpty(skills, "Skills list cannot be empty"); + if (skills.size() > 8) { + throw new IllegalArgumentException("Maximum of 8 skills per request. Provided: " + skills.size()); + } + } + + public static SkillContainerBuilder builder() { + return new SkillContainerBuilder(); + } + + public static final class SkillContainerBuilder { + + private final List skills = new ArrayList<>(); + + public SkillContainerBuilder skill(Skill skill) { + Assert.notNull(skill, "Skill cannot be null"); + this.skills.add(skill); + return this; + } + + public SkillContainerBuilder skill(SkillType type, String skillId) { + return skill(new Skill(type, skillId)); + } + + public SkillContainerBuilder skill(SkillType type, String skillId, String version) { + return skill(new Skill(type, skillId, version)); + } + + public SkillContainerBuilder anthropicSkill(AnthropicSkill skill) { + return skill(skill.toSkill()); + } + + public SkillContainerBuilder anthropicSkill(AnthropicSkill skill, String version) { + return skill(skill.toSkill(version)); + } + + public SkillContainerBuilder customSkill(String skillId) { + return skill(SkillType.CUSTOM, skillId); + } + + public SkillContainerBuilder customSkill(String skillId, String version) { + return skill(SkillType.CUSTOM, skillId, version); + } + + public SkillContainerBuilder skills(List skills) { + Assert.notNull(skills, "Skills list cannot be null"); + this.skills.addAll(skills); + return this; + } + + public SkillContainer build() { + return new SkillContainer(new ArrayList<>(this.skills)); + } + + } + + } + + // @formatter:off + /** + * Metadata for a file generated by Claude Skills or uploaded via Files API. + * Files expire after a certain period (typically 24 hours). + * + * @param id Unique file identifier (format: file_*) + * @param filename Original filename with extension + * @param size File size in bytes + * @param mimeType MIME type (e.g., application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) + * @param createdAt When the file was created (ISO 8601 timestamp) + * @param expiresAt When the file will be automatically deleted (ISO 8601 timestamp) + */ + @JsonInclude(Include.NON_NULL) + public record FileMetadata( + @JsonProperty("id") String id, + @JsonProperty("filename") String filename, + @JsonProperty("size") Long size, + @JsonProperty("mime_type") String mimeType, + @JsonProperty("created_at") String createdAt, + @JsonProperty("expires_at") String expiresAt) { + } + + /** + * Paginated list of files response from the Files API. + * + * @param data List of file metadata objects + * @param hasMore Whether more results exist + * @param nextPage Pagination token for next page + */ + @JsonInclude(Include.NON_NULL) + public record FilesListResponse( + @JsonProperty("data") List data, + @JsonProperty("has_more") Boolean hasMore, + @JsonProperty("next_page") String nextPage) { + } + // @formatter:on + /** * The event type of the streamed chunk. */ @@ -530,18 +891,20 @@ public record ChatCompletionRequest( @JsonProperty("top_k") Integer topK, @JsonProperty("tools") List tools, @JsonProperty("tool_choice") ToolChoice toolChoice, - @JsonProperty("thinking") ThinkingConfig thinking) { + @JsonProperty("thinking") ThinkingConfig thinking, + @JsonProperty("container") SkillContainer container) { // @formatter:on public ChatCompletionRequest(String model, List messages, Object system, Integer maxTokens, Double temperature, Boolean stream) { - this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null); + this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null, + null); } public ChatCompletionRequest(String model, List messages, Object system, Integer maxTokens, List stopSequences, Double temperature, Boolean stream) { this(model, messages, system, maxTokens, null, stopSequences, stream, temperature, null, null, null, null, - null); + null, null); } public static ChatCompletionRequestBuilder builder() { @@ -619,6 +982,8 @@ public static final class ChatCompletionRequestBuilder { private ChatCompletionRequest.ThinkingConfig thinking; + private SkillContainer container; + private ChatCompletionRequestBuilder() { } @@ -636,6 +1001,7 @@ private ChatCompletionRequestBuilder(ChatCompletionRequest request) { this.tools = request.tools; this.toolChoice = request.toolChoice; this.thinking = request.thinking; + this.container = request.container; } public ChatCompletionRequestBuilder model(ChatModel model) { @@ -713,10 +1079,22 @@ public ChatCompletionRequestBuilder thinking(ThinkingType type, Integer budgetTo return this; } + public ChatCompletionRequestBuilder container(SkillContainer container) { + this.container = container; + return this; + } + + public ChatCompletionRequestBuilder skills(List skills) { + if (skills != null && !skills.isEmpty()) { + this.container = new SkillContainer(skills); + } + return this; + } + public ChatCompletionRequest build() { return new ChatCompletionRequest(this.model, this.messages, this.system, this.maxTokens, this.metadata, this.stopSequences, this.stream, this.temperature, this.topP, this.topK, this.tools, - this.toolChoice, this.thinking); + this.toolChoice, this.thinking, this.container); } } @@ -832,7 +1210,7 @@ public record ContentBlock( // tool_result response only @JsonProperty("tool_use_id") String toolUseId, - @JsonProperty("content") String content, + @JsonProperty("content") Object content, // Thinking only @JsonProperty("signature") String signature, @@ -847,7 +1225,11 @@ public record ContentBlock( // Citation fields @JsonProperty("title") String title, @JsonProperty("context") String context, - @JsonProperty("citations") Object citations // Can be CitationsConfig for requests or List for responses + @JsonProperty("citations") Object citations, // Can be CitationsConfig for requests or List for responses + + // File fields (for Skills-generated files) + @JsonProperty("file_id") String fileId, + @JsonProperty("filename") String filename ) { // @formatter:on @@ -866,7 +1248,8 @@ public ContentBlock(String mediaType, String data) { * @param source The source of the content. */ public ContentBlock(Type type, Source source) { - this(type, source, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + this(type, source, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null); } /** @@ -874,8 +1257,8 @@ public ContentBlock(Type type, Source source) { * @param source The source of the content. */ public ContentBlock(Source source) { - this(Type.IMAGE, source, null, null, null, null, null, null, null, null, null, null, null, null, null, - null); + this(Type.IMAGE, source, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null); } /** @@ -883,11 +1266,13 @@ public ContentBlock(Source source) { * @param text The text of the content. */ public ContentBlock(String text) { - this(Type.TEXT, null, text, null, null, null, null, null, null, null, null, null, null, null, null, null); + this(Type.TEXT, null, text, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null); } public ContentBlock(String text, CacheControl cache) { - this(Type.TEXT, null, text, null, null, null, null, null, null, null, null, null, cache, null, null, null); + this(Type.TEXT, null, text, null, null, null, null, null, null, null, null, null, cache, null, null, null, + null, null); } // Tool result @@ -897,9 +1282,9 @@ public ContentBlock(String text, CacheControl cache) { * @param toolUseId The id of the tool use. * @param content The content of the tool result. */ - public ContentBlock(Type type, String toolUseId, String content) { - this(type, null, null, null, null, null, null, toolUseId, content, null, null, null, null, null, null, - null); + public ContentBlock(Type type, String toolUseId, Object content) { + this(type, null, null, null, null, null, null, toolUseId, content, null, null, null, null, null, null, null, + null, null); } /** @@ -910,7 +1295,8 @@ public ContentBlock(Type type, String toolUseId, String content) { * @param index The index of the content block. */ public ContentBlock(Type type, Source source, String text, Integer index) { - this(type, source, text, index, null, null, null, null, null, null, null, null, null, null, null, null); + this(type, source, text, index, null, null, null, null, null, null, null, null, null, null, null, null, + null, null); } // Tool use input JSON delta streaming @@ -922,7 +1308,8 @@ public ContentBlock(Type type, Source source, String text, Integer index) { * @param input The input of the tool use. */ public ContentBlock(Type type, String id, String name, Map input) { - this(type, null, null, null, id, name, input, null, null, null, null, null, null, null, null, null); + this(type, null, null, null, id, name, input, null, null, null, null, null, null, null, null, null, null, + null); } /** @@ -936,7 +1323,7 @@ public ContentBlock(Type type, String id, String name, Map input public ContentBlock(Source source, String title, String context, boolean citationsEnabled, CacheControl cacheControl) { this(Type.DOCUMENT, source, null, null, null, null, null, null, null, null, null, null, cacheControl, title, - context, citationsEnabled ? new CitationsConfig(true) : null); + context, citationsEnabled ? new CitationsConfig(true) : null, null, null); } public static ContentBlockBuilder from(ContentBlock contentBlock) { @@ -1016,7 +1403,38 @@ public enum Type { * Redacted Thinking message. */ @JsonProperty("redacted_thinking") - REDACTED_THINKING("redacted_thinking"); + REDACTED_THINKING("redacted_thinking"), + + /** + * File content block representing a file generated by Skills. Used in + * {@link org.springframework.ai.anthropic.SkillsResponseHelper} to extract + * file IDs for downloading generated documents. + */ + @JsonProperty("file") + FILE("file"), + + /** + * Bash code execution tool result returned in Skills responses. Observed in + * actual API responses where file IDs are nested within this content block. + * Required for JSON deserialization. + */ + @JsonProperty("bash_code_execution_tool_result") + BASH_CODE_EXECUTION_TOOL_RESULT("bash_code_execution_tool_result"), + + /** + * Text editor code execution tool result returned in Skills responses. + * Observed in actual API responses. Required for JSON deserialization. + */ + @JsonProperty("text_editor_code_execution_tool_result") + TEXT_EDITOR_CODE_EXECUTION_TOOL_RESULT("text_editor_code_execution_tool_result"), + + /** + * Server-side tool use returned in Skills responses. Observed in actual API + * responses when Skills invoke server-side tools. Required for JSON + * deserialization. + */ + @JsonProperty("server_tool_use") + SERVER_TOOL_USE("server_tool_use"); public final String value; @@ -1090,7 +1508,7 @@ public static class ContentBlockBuilder { private String toolUseId; - private String content; + private Object content; private String signature; @@ -1165,7 +1583,7 @@ public ContentBlockBuilder toolUseId(String toolUseId) { return this; } - public ContentBlockBuilder content(String content) { + public ContentBlockBuilder content(Object content) { this.content = content; return this; } @@ -1193,7 +1611,7 @@ public ContentBlockBuilder cacheControl(CacheControl cacheControl) { public ContentBlock build() { return new ContentBlock(this.type, this.source, this.text, this.index, this.id, this.name, this.input, this.toolUseId, this.content, this.signature, this.thinking, this.data, this.cacheControl, - this.title, this.context, this.citations); + this.title, this.context, this.citations, null, null); } } @@ -1206,6 +1624,8 @@ public ContentBlock build() { /** * Tool description. * + * @param type The type of the tool (e.g., "code_execution_20250825" for code + * execution). * @param name The name of the tool. * @param description A description of the tool. * @param inputSchema The input schema of the tool. @@ -1214,6 +1634,7 @@ public ContentBlock build() { @JsonInclude(Include.NON_NULL) public record Tool( // @formatter:off + @JsonProperty("type") String type, @JsonProperty("name") String name, @JsonProperty("description") String description, @JsonProperty("input_schema") Map inputSchema, @@ -1221,10 +1642,17 @@ public record Tool( // @formatter:on /** - * Constructor for backward compatibility without cache control. + * Constructor for backward compatibility without type or cache control. */ public Tool(String name, String description, Map inputSchema) { - this(name, description, inputSchema, null); + this(null, name, description, inputSchema, null); + } + + /** + * Constructor for backward compatibility without cache control. + */ + public Tool(String type, String name, String description, Map inputSchema) { + this(type, name, description, inputSchema, null); } } @@ -1377,8 +1805,19 @@ public record ChatCompletionResponse( @JsonProperty("model") String model, @JsonProperty("stop_reason") String stopReason, @JsonProperty("stop_sequence") String stopSequence, - @JsonProperty("usage") Usage usage) { + @JsonProperty("usage") Usage usage, + @JsonProperty("container") Container container) { // @formatter:on + + /** + * Container information for Skills execution context. Contains container_id that + * can be reused across multi-turn conversations. + * + * @param id Container identifier (format: container_*) + */ + @JsonInclude(Include.NON_NULL) + public record Container(@JsonProperty("id") String id) { + } } // CB DELTA EVENT diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java index fbb26705450..5441547d61c 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java @@ -165,7 +165,7 @@ else if (event.type().equals(EventType.CONTENT_BLOCK_START)) { else if (contentBlockStartEvent.contentBlock() instanceof ContentBlockThinking thinkingBlock) { ContentBlock cb = new ContentBlock(Type.THINKING, null, null, contentBlockStartEvent.index(), null, null, null, null, null, thinkingBlock.signature(), thinkingBlock.thinking(), null, null, null, - null, null); + null, null, null, null); contentBlockReference.get().withType(event.type().name()).withContent(List.of(cb)); } else { @@ -182,12 +182,13 @@ else if (event.type().equals(EventType.CONTENT_BLOCK_DELTA)) { } else if (contentBlockDeltaEvent.delta() instanceof ContentBlockDeltaThinking thinking) { ContentBlock cb = new ContentBlock(Type.THINKING_DELTA, null, null, contentBlockDeltaEvent.index(), - null, null, null, null, null, null, thinking.thinking(), null, null, null, null, null); + null, null, null, null, null, null, thinking.thinking(), null, null, null, null, null, null, + null); contentBlockReference.get().withType(event.type().name()).withContent(List.of(cb)); } else if (contentBlockDeltaEvent.delta() instanceof ContentBlockDeltaSignature sig) { ContentBlock cb = new ContentBlock(Type.SIGNATURE_DELTA, null, null, contentBlockDeltaEvent.index(), - null, null, null, null, null, sig.signature(), null, null, null, null, null, null); + null, null, null, null, null, sig.signature(), null, null, null, null, null, null, null, null); contentBlockReference.get().withType(event.type().name()).withContent(List.of(cb)); } else { @@ -306,7 +307,7 @@ public ChatCompletionResponseBuilder withUsage(Usage usage) { public ChatCompletionResponse build() { return new ChatCompletionResponse(this.id, this.type, this.role, this.content, this.model, this.stopReason, - this.stopSequence, this.usage); + this.stopSequence, this.usage, null); } } diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelSkillsTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelSkillsTests.java new file mode 100644 index 00000000000..b4483336756 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelSkillsTests.java @@ -0,0 +1,305 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic; + +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicSkill; +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.retry.support.RetryTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link AnthropicChatModel} with Skills support. + * + * @author Soby Chacko + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class AnthropicChatModelSkillsTests { + + @Mock + private AnthropicApi anthropicApi; + + private AnthropicChatModel createChatModel(AnthropicChatOptions defaultOptions) { + RetryTemplate retryTemplate = RetryUtils.SHORT_RETRY_TEMPLATE; + ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + ToolCallingManager toolCallingManager = ToolCallingManager.builder().build(); + return new AnthropicChatModel(this.anthropicApi, defaultOptions, toolCallingManager, retryTemplate, + observationRegistry); + } + + @Test + void shouldIncludeSkillsFromRequestOptions() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .build(); + + Prompt prompt = new Prompt("Create a spreadsheet", requestOptions); + + ChatCompletionRequest request = chatModel.createRequest(prompt, false); + + assertThat(request.container()).isNotNull(); + assertThat(request.container().skills()).hasSize(1); + assertThat(request.container().skills().get(0).skillId()).isEqualTo("xlsx"); + } + + @Test + void shouldIncludeSkillsFromDefaultOptions() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .anthropicSkill(AnthropicSkill.PPTX) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + // Pass empty options to avoid null check failures + Prompt prompt = new Prompt("Create a presentation", AnthropicChatOptions.builder().build()); + + ChatCompletionRequest request = chatModel.createRequest(prompt, false); + + assertThat(request.container()).isNotNull(); + assertThat(request.container().skills()).hasSize(1); + assertThat(request.container().skills().get(0).skillId()).isEqualTo("pptx"); + } + + @Test + void shouldPrioritizeRequestOptionsOverDefaultOptions() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .anthropicSkill(AnthropicSkill.PPTX) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .build(); + + Prompt prompt = new Prompt("Create a spreadsheet", requestOptions); + + ChatCompletionRequest request = chatModel.createRequest(prompt, false); + + assertThat(request.container()).isNotNull(); + assertThat(request.container().skills()).hasSize(1); + assertThat(request.container().skills().get(0).skillId()).isEqualTo("xlsx"); + } + + @Test + void shouldIncludeMultipleSkills() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .anthropicSkill(AnthropicSkill.PPTX) + .customSkill("my-custom-skill") + .build(); + + Prompt prompt = new Prompt("Create documents", requestOptions); + + ChatCompletionRequest request = chatModel.createRequest(prompt, false); + + assertThat(request.container()).isNotNull(); + assertThat(request.container().skills()).hasSize(3); + assertThat(request.container().skills().get(0).skillId()).isEqualTo("xlsx"); + assertThat(request.container().skills().get(1).skillId()).isEqualTo("pptx"); + assertThat(request.container().skills().get(2).skillId()).isEqualTo("my-custom-skill"); + } + + @Test + void shouldHandleNullSkillsGracefully() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + // Pass empty options to avoid null check failures + Prompt prompt = new Prompt("Simple question", AnthropicChatOptions.builder().build()); + + ChatCompletionRequest request = chatModel.createRequest(prompt, false); + + assertThat(request.container()).isNull(); + } + + @Test + void shouldIncludeSkillsWithVersion() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX, "20251013") + .build(); + + Prompt prompt = new Prompt("Create a spreadsheet", requestOptions); + + ChatCompletionRequest request = chatModel.createRequest(prompt, false); + + assertThat(request.container()).isNotNull(); + assertThat(request.container().skills()).hasSize(1); + assertThat(request.container().skills().get(0).skillId()).isEqualTo("xlsx"); + assertThat(request.container().skills().get(0).version()).isEqualTo("20251013"); + } + + @Test + void shouldAddSkillsBetaHeaderWhenSkillsPresent() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .build(); + + Prompt prompt = new Prompt("Create a spreadsheet", requestOptions); + + chatModel.createRequest(prompt, false); + + assertThat(requestOptions.getHttpHeaders()).isNotNull(); + assertThat(requestOptions.getHttpHeaders()).containsKey("anthropic-beta"); + String betaHeader = requestOptions.getHttpHeaders().get("anthropic-beta"); + assertThat(betaHeader).contains(AnthropicApi.BETA_SKILLS); + assertThat(betaHeader).contains(AnthropicApi.BETA_CODE_EXECUTION); + assertThat(betaHeader).contains(AnthropicApi.BETA_FILES_API); + } + + @Test + void shouldNotAddSkillsBetaHeaderWhenNoSkills() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder().build(); + + Prompt prompt = new Prompt("Simple question", requestOptions); + + chatModel.createRequest(prompt, false); + + assertThat(requestOptions.getHttpHeaders().get("anthropic-beta")).isNull(); + } + + @Test + void shouldAppendSkillsBetaHeaderToExistingBetaHeaders() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + java.util.Map existingHeaders = new java.util.HashMap<>(); + existingHeaders.put("anthropic-beta", "some-other-beta"); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .httpHeaders(existingHeaders) + .build(); + + Prompt prompt = new Prompt("Create a spreadsheet", requestOptions); + + chatModel.createRequest(prompt, false); + + String betaHeader = requestOptions.getHttpHeaders().get("anthropic-beta"); + assertThat(betaHeader).contains("some-other-beta") + .contains(AnthropicApi.BETA_SKILLS) + .contains(AnthropicApi.BETA_CODE_EXECUTION) + .contains(AnthropicApi.BETA_FILES_API); + } + + @Test + void shouldAutomaticallyAddCodeExecutionToolWhenSkillsPresent() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .build(); + + Prompt prompt = new Prompt("Create a spreadsheet", requestOptions); + + ChatCompletionRequest request = chatModel.createRequest(prompt, false); + + // Verify code_execution tool is automatically added + assertThat(request.tools()).isNotNull(); + assertThat(request.tools()).hasSize(1); + assertThat(request.tools().get(0).name()).isEqualTo("code_execution"); + } + + @Test + void shouldNotDuplicateCodeExecutionToolIfAlreadyPresent() { + AnthropicChatOptions defaultOptions = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .build(); + + AnthropicChatModel chatModel = createChatModel(defaultOptions); + + AnthropicChatOptions requestOptions = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .build(); + + Prompt prompt = new Prompt("Create a spreadsheet", requestOptions); + + // Note: We can't easily test this without exposing more of the internal state, + // but the implementation checks for existing code_execution tool + ChatCompletionRequest request = chatModel.createRequest(prompt, false); + + // Should have exactly 1 tool (code_execution), not duplicated + assertThat(request.tools()).isNotNull(); + assertThat(request.tools()).hasSize(1); + assertThat(request.tools().get(0).name()).isEqualTo("code_execution"); + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatOptionsSkillsTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatOptionsSkillsTests.java new file mode 100644 index 00000000000..ea4ca76eee4 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatOptionsSkillsTests.java @@ -0,0 +1,208 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicSkill; +import org.springframework.ai.anthropic.api.AnthropicApi.Skill; +import org.springframework.ai.anthropic.api.AnthropicApi.SkillContainer; +import org.springframework.ai.anthropic.api.AnthropicApi.SkillType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link AnthropicChatOptions} with Skills support. + * + * @author Soby Chacko + * @since 2.0.0 + */ +class AnthropicChatOptionsSkillsTests { + + @Test + void shouldBuildOptionsWithSingleSkill() { + AnthropicChatOptions options = AnthropicChatOptions.builder().anthropicSkill(AnthropicSkill.XLSX).build(); + + assertThat(options.getSkillContainer()).isNotNull(); + assertThat(options.getSkillContainer().skills()).hasSize(1); + assertThat(options.getSkillContainer().skills().get(0).skillId()).isEqualTo("xlsx"); + assertThat(options.getSkillContainer().skills().get(0).type()).isEqualTo(SkillType.ANTHROPIC); + } + + @Test + void shouldBuildOptionsWithMultipleSkills() { + AnthropicChatOptions options = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .anthropicSkill(AnthropicSkill.PPTX) + .customSkill("my-custom-skill") + .build(); + + assertThat(options.getSkillContainer()).isNotNull(); + assertThat(options.getSkillContainer().skills()).hasSize(3); + assertThat(options.getSkillContainer().skills().get(0).skillId()).isEqualTo("xlsx"); + assertThat(options.getSkillContainer().skills().get(1).skillId()).isEqualTo("pptx"); + assertThat(options.getSkillContainer().skills().get(2).skillId()).isEqualTo("my-custom-skill"); + assertThat(options.getSkillContainer().skills().get(2).type()).isEqualTo(SkillType.CUSTOM); + } + + @Test + void shouldBuildOptionsWithSkillContainer() { + SkillContainer container = SkillContainer.builder().anthropicSkill(AnthropicSkill.DOCX).build(); + + AnthropicChatOptions options = AnthropicChatOptions.builder().skillContainer(container).build(); + + assertThat(options.getSkillContainer()).isSameAs(container); + assertThat(options.getSkillContainer().skills()).hasSize(1); + } + + @Test + void shouldBuildOptionsWithSkillVersion() { + AnthropicChatOptions options = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX, "20251013") + .build(); + + assertThat(options.getSkillContainer()).isNotNull(); + assertThat(options.getSkillContainer().skills()).hasSize(1); + assertThat(options.getSkillContainer().skills().get(0).version()).isEqualTo("20251013"); + } + + @Test + void shouldBuildOptionsWithCustomSkillVersion() { + AnthropicChatOptions options = AnthropicChatOptions.builder().customSkill("my-skill", "1.0.0").build(); + + assertThat(options.getSkillContainer()).isNotNull(); + assertThat(options.getSkillContainer().skills()).hasSize(1); + assertThat(options.getSkillContainer().skills().get(0).skillId()).isEqualTo("my-skill"); + assertThat(options.getSkillContainer().skills().get(0).version()).isEqualTo("1.0.0"); + assertThat(options.getSkillContainer().skills().get(0).type()).isEqualTo(SkillType.CUSTOM); + } + + @Test + void shouldCopyOptionsWithSkills() { + SkillContainer container = SkillContainer.builder().anthropicSkill(AnthropicSkill.PDF).build(); + + AnthropicChatOptions original = AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(2048) + .skillContainer(container) + .build(); + + AnthropicChatOptions copy = AnthropicChatOptions.fromOptions(original); + + assertThat(copy.getSkillContainer()).isNotNull(); + assertThat(copy.getSkillContainer()).isSameAs(original.getSkillContainer()); + assertThat(copy.getSkillContainer().skills()).hasSize(1); + assertThat(copy.getModel()).isEqualTo(original.getModel()); + assertThat(copy.getMaxTokens()).isEqualTo(original.getMaxTokens()); + } + + @Test + void shouldIncludeSkillsInEqualsAndHashCode() { + SkillContainer container = SkillContainer.builder().anthropicSkill(AnthropicSkill.XLSX).build(); + + AnthropicChatOptions options1 = AnthropicChatOptions.builder().skillContainer(container).build(); + + AnthropicChatOptions options2 = AnthropicChatOptions.builder().skillContainer(container).build(); + + AnthropicChatOptions options3 = AnthropicChatOptions.builder().anthropicSkill(AnthropicSkill.PPTX).build(); + + assertThat(options1).isEqualTo(options2); + assertThat(options1.hashCode()).isEqualTo(options2.hashCode()); + assertThat(options1).isNotEqualTo(options3); + } + + @Test + void shouldBuildOptionsWithSkillMethod() { + Skill skill = new Skill(SkillType.ANTHROPIC, "docx", "latest"); + + AnthropicChatOptions options = AnthropicChatOptions.builder().skill(skill).build(); + + assertThat(options.getSkillContainer()).isNotNull(); + assertThat(options.getSkillContainer().skills()).hasSize(1); + assertThat(options.getSkillContainer().skills().get(0)).isSameAs(skill); + } + + @Test + void shouldAllowNullSkillContainer() { + AnthropicChatOptions options = AnthropicChatOptions.builder().model("claude-sonnet-4-5").build(); + + assertThat(options.getSkillContainer()).isNull(); + } + + @Test + void shouldAddMultipleSkillsSequentially() { + AnthropicChatOptions options = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .anthropicSkill(AnthropicSkill.PPTX) + .anthropicSkill(AnthropicSkill.DOCX) + .build(); + + assertThat(options.getSkillContainer()).isNotNull(); + assertThat(options.getSkillContainer().skills()).hasSize(3); + } + + @Test + void shouldPreserveExistingSkillsWhenAddingNew() { + SkillContainer initialContainer = SkillContainer.builder().anthropicSkill(AnthropicSkill.XLSX).build(); + + AnthropicChatOptions options = AnthropicChatOptions.builder() + .skillContainer(initialContainer) + .anthropicSkill(AnthropicSkill.PPTX) + .build(); + + assertThat(options.getSkillContainer()).isNotNull(); + assertThat(options.getSkillContainer().skills()).hasSize(2); + assertThat(options.getSkillContainer().skills().get(0).skillId()).isEqualTo("xlsx"); + assertThat(options.getSkillContainer().skills().get(1).skillId()).isEqualTo("pptx"); + } + + @Test + void shouldSetSkillContainerViaGetter() { + AnthropicChatOptions options = new AnthropicChatOptions(); + SkillContainer container = SkillContainer.builder().anthropicSkill(AnthropicSkill.PDF).build(); + + options.setSkillContainer(container); + + assertThat(options.getSkillContainer()).isSameAs(container); + } + + @Test + void shouldCopyOptionsWithNullSkills() { + AnthropicChatOptions original = AnthropicChatOptions.builder().model("claude-sonnet-4-5").build(); + + AnthropicChatOptions copy = AnthropicChatOptions.fromOptions(original); + + assertThat(copy.getSkillContainer()).isNull(); + } + + @Test + void shouldMaintainSkillOrderWhenAdding() { + AnthropicChatOptions options = AnthropicChatOptions.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .customSkill("skill-a") + .anthropicSkill(AnthropicSkill.PPTX) + .customSkill("skill-b") + .build(); + + assertThat(options.getSkillContainer().skills()).hasSize(4); + assertThat(options.getSkillContainer().skills().get(0).skillId()).isEqualTo("xlsx"); + assertThat(options.getSkillContainer().skills().get(1).skillId()).isEqualTo("skill-a"); + assertThat(options.getSkillContainer().skills().get(2).skillId()).isEqualTo("pptx"); + assertThat(options.getSkillContainer().skills().get(3).skillId()).isEqualTo("skill-b"); + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicSkillsIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicSkillsIT.java new file mode 100644 index 00000000000..ffb12697abe --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicSkillsIT.java @@ -0,0 +1,268 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicSkill; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Anthropic Skills API support. + * + * @author Soby Chacko + * @since 2.0.0 + */ +@SpringBootTest(classes = AnthropicSkillsIT.Config.class) +@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+") +class AnthropicSkillsIT { + + private static final Logger logger = LoggerFactory.getLogger(AnthropicSkillsIT.class); + + @Autowired + private AnthropicChatModel chatModel; + + @Autowired + private AnthropicApi anthropicApi; + + @Test + void shouldGenerateExcelWithXlsxSkill(@TempDir Path tempDir) throws IOException { + // Create a prompt requesting Excel generation + // Use explicit language to trigger skill execution + UserMessage userMessage = new UserMessage( + "Please create an Excel file (.xlsx) with 3 columns: Name, Age, City. Add 5 sample rows of data. " + + "Generate the actual file using the xlsx skill."); + + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(4096) + .anthropicSkill(AnthropicSkill.XLSX) + .toolChoice(new AnthropicApi.ToolChoiceAny()) + .internalToolExecutionEnabled(false) + .build(); + + Prompt prompt = new Prompt(List.of(userMessage), options); + + // Call the model + ChatResponse response = this.chatModel.call(prompt); + + // Verify response exists and is not empty + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + String responseText = response.getResult().getOutput().getText(); + assertThat(responseText).as("Response text should not be blank").isNotBlank(); + + // Log the response for debugging + logger.info("XLSX Skill Response: {}", responseText); + + // Log metadata for debugging + if (response.getMetadata() != null) { + logger.info("Response Metadata: {}", response.getMetadata()); + } + + // Verify the response mentions Excel/spreadsheet creation + // The exact content may vary, but it should reference the created file + assertThat(responseText.toLowerCase()).as("Response should mention spreadsheet or Excel") + .containsAnyOf("spreadsheet", "excel", "xlsx", "created", "file"); + + // Extract file IDs from the response + List fileIds = SkillsResponseHelper.extractFileIds(response); + assertThat(fileIds).as("Skills response should contain at least one file ID").isNotEmpty(); + + logger.info("Extracted {} file ID(s): {}", fileIds.size(), fileIds); + + // Download all files + List downloadedFiles = SkillsResponseHelper.downloadAllFiles(response, this.anthropicApi, tempDir); + assertThat(downloadedFiles).as("Should download at least one file").isNotEmpty(); + + // Verify files exist and have content + for (Path filePath : downloadedFiles) { + assertThat(filePath).exists(); + assertThat(Files.size(filePath)).as("Downloaded file should not be empty").isGreaterThan(0); + logger.info("Downloaded file: {} ({} bytes)", filePath.getFileName(), Files.size(filePath)); + } + + // Verify at least one Excel file was created + boolean hasXlsxFile = downloadedFiles.stream() + .anyMatch(path -> path.toString().toLowerCase().endsWith(".xlsx")); + assertThat(hasXlsxFile).as("At least one .xlsx file should be downloaded").isTrue(); + } + + @Test + void shouldGeneratePowerPointWithPptxSkill(@TempDir Path tempDir) throws IOException { + // Create a prompt requesting PowerPoint generation + // Use explicit language to trigger skill execution + UserMessage userMessage = new UserMessage( + "Please create a PowerPoint presentation file (.pptx) about Spring AI with 3 slides: " + + "Introduction, Features, and Conclusion. Generate the actual file using the pptx skill."); + + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(4096) + .anthropicSkill(AnthropicSkill.PPTX) + .toolChoice(new AnthropicApi.ToolChoiceAny()) + .internalToolExecutionEnabled(false) + .build(); + + Prompt prompt = new Prompt(List.of(userMessage), options); + + // Call the model + ChatResponse response = this.chatModel.call(prompt); + + // Verify response exists and is not empty + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + String responseText = response.getResult().getOutput().getText(); + assertThat(responseText).as("Response text should not be blank").isNotBlank(); + + // Log the response for debugging + logger.info("PPTX Skill Response: {}", responseText); + + // Verify the response mentions PowerPoint/presentation creation + assertThat(responseText.toLowerCase()).as("Response should mention presentation or PowerPoint") + .containsAnyOf("presentation", "powerpoint", "pptx", "slide", "created", "file"); + + // Extract file IDs from the response + List fileIds = SkillsResponseHelper.extractFileIds(response); + assertThat(fileIds).as("Skills response should contain at least one file ID").isNotEmpty(); + + logger.info("Extracted {} file ID(s): {}", fileIds.size(), fileIds); + + // Download all files + List downloadedFiles = SkillsResponseHelper.downloadAllFiles(response, this.anthropicApi, tempDir); + assertThat(downloadedFiles).as("Should download at least one file").isNotEmpty(); + + // Verify files exist and have content + for (Path filePath : downloadedFiles) { + assertThat(filePath).exists(); + assertThat(Files.size(filePath)).as("Downloaded file should not be empty").isGreaterThan(0); + logger.info("Downloaded file: {} ({} bytes)", filePath.getFileName(), Files.size(filePath)); + } + + // Verify at least one PowerPoint file was created + boolean hasPptxFile = downloadedFiles.stream() + .anyMatch(path -> path.toString().toLowerCase().endsWith(".pptx")); + assertThat(hasPptxFile).as("At least one .pptx file should be downloaded").isTrue(); + } + + @Test + void shouldUseMultipleSkills(@TempDir Path tempDir) throws IOException { + // Create a prompt that could use multiple skills + // Use explicit language to trigger skill execution + UserMessage userMessage = new UserMessage( + "Please create two files: 1) An Excel file (.xlsx) with sample sales data (use xlsx skill), " + + "and 2) A PowerPoint presentation file (.pptx) summarizing the data (use pptx skill). " + + "Generate the actual files."); + + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .maxTokens(4096) + .anthropicSkill(AnthropicSkill.XLSX) + .anthropicSkill(AnthropicSkill.PPTX) + .toolChoice(new AnthropicApi.ToolChoiceAny()) + .internalToolExecutionEnabled(false) + .build(); + + Prompt prompt = new Prompt(List.of(userMessage), options); + + // Call the model + ChatResponse response = this.chatModel.call(prompt); + + // Verify response exists and is not empty + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotEmpty(); + String responseText = response.getResult().getOutput().getText(); + assertThat(responseText).as("Response text should not be blank").isNotBlank(); + + // Log the response for debugging + logger.info("Multiple Skills Response: {}", responseText); + + // Verify the response mentions document creation + assertThat(responseText.toLowerCase()).as("Response should mention file creation") + .containsAnyOf("spreadsheet", "presentation", "created", "file", "xlsx", "pptx"); + + // Extract file IDs from the response + List fileIds = SkillsResponseHelper.extractFileIds(response); + assertThat(fileIds).as("Skills response should contain at least one file ID").isNotEmpty(); + + logger.info("Extracted {} file ID(s): {}", fileIds.size(), fileIds); + + // Download all files + List downloadedFiles = SkillsResponseHelper.downloadAllFiles(response, this.anthropicApi, tempDir); + assertThat(downloadedFiles).as("Should download at least one file").isNotEmpty(); + assertThat(downloadedFiles.size()).as("Should download multiple files").isGreaterThanOrEqualTo(2); + + // Verify files exist and have content + for (Path filePath : downloadedFiles) { + assertThat(filePath).exists(); + assertThat(Files.size(filePath)).as("Downloaded file should not be empty").isGreaterThan(0); + logger.info("Downloaded file: {} ({} bytes)", filePath.getFileName(), Files.size(filePath)); + } + + // Verify both file types were created + boolean hasXlsxFile = downloadedFiles.stream() + .anyMatch(path -> path.toString().toLowerCase().endsWith(".xlsx")); + boolean hasPptxFile = downloadedFiles.stream() + .anyMatch(path -> path.toString().toLowerCase().endsWith(".pptx")); + + assertThat(hasXlsxFile || hasPptxFile).as("At least one .xlsx or .pptx file should be downloaded").isTrue(); + } + + @SpringBootConfiguration + public static class Config { + + @Bean + public AnthropicApi anthropicApi() { + return AnthropicApi.builder().apiKey(getApiKey()).build(); + } + + private String getApiKey() { + String apiKey = System.getenv("ANTHROPIC_API_KEY"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "You must provide an API key. Put it in an environment variable under the name ANTHROPIC_API_KEY"); + } + return apiKey; + } + + @Bean + public AnthropicChatModel anthropicChatModel(AnthropicApi api) { + return AnthropicChatModel.builder().anthropicApi(api).build(); + } + + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/SkillsResponseHelperTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/SkillsResponseHelperTests.java new file mode 100644 index 00000000000..344bb2459af --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/SkillsResponseHelperTests.java @@ -0,0 +1,234 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.model.ChatResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link SkillsResponseHelper}. + * + * @author Soby Chacko + * @since 2.0.0 + */ +class SkillsResponseHelperTests { + + @Test + void shouldReturnEmptyListForNullResponse() { + List fileIds = SkillsResponseHelper.extractFileIds(null); + assertThat(fileIds).isEmpty(); + } + + @Test + void shouldReturnEmptyListForResponseWithoutMetadata() { + ChatResponse response = new ChatResponse(List.of()); + List fileIds = SkillsResponseHelper.extractFileIds(response); + assertThat(fileIds).isEmpty(); + } + + @Test + void shouldReturnEmptyListWhenNoFileContentBlocks() { + // Create a response with text content but no files + ContentBlock textBlock = new ContentBlock("Sample text response"); + ChatCompletionResponse apiResponse = new ChatCompletionResponse("msg_123", "message", null, List.of(textBlock), + "claude-sonnet-4-5", null, null, null, null); + + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .keyValue("anthropic-response", apiResponse) + .build(); + + ChatResponse response = new ChatResponse(List.of(), metadata); + + List fileIds = SkillsResponseHelper.extractFileIds(response); + assertThat(fileIds).isEmpty(); + } + + @Test + void shouldExtractSingleFileId() { + // Create a file content block + ContentBlock fileBlock = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, "file_abc123", "report.xlsx"); + + ChatCompletionResponse apiResponse = new ChatCompletionResponse("msg_123", "message", null, List.of(fileBlock), + "claude-sonnet-4-5", null, null, null, null); + + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .keyValue("anthropic-response", apiResponse) + .build(); + + ChatResponse response = new ChatResponse(List.of(), metadata); + + List fileIds = SkillsResponseHelper.extractFileIds(response); + + assertThat(fileIds).hasSize(1); + assertThat(fileIds).containsExactly("file_abc123"); + } + + @Test + void shouldExtractMultipleFileIds() { + // Create multiple file content blocks + ContentBlock file1 = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "file_123", "report.xlsx"); + + ContentBlock file2 = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "file_456", "presentation.pptx"); + + ContentBlock file3 = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "file_789", "document.docx"); + + ChatCompletionResponse apiResponse = new ChatCompletionResponse("msg_123", "message", null, + List.of(file1, file2, file3), "claude-sonnet-4-5", null, null, null, null); + + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .keyValue("anthropic-response", apiResponse) + .build(); + + ChatResponse response = new ChatResponse(List.of(), metadata); + + List fileIds = SkillsResponseHelper.extractFileIds(response); + + assertThat(fileIds).hasSize(3); + assertThat(fileIds).containsExactly("file_123", "file_456", "file_789"); + } + + @Test + void shouldExtractFileIdsFromMixedContent() { + // Mix of text and file content blocks + ContentBlock textBlock = new ContentBlock("I've created the files you requested"); + + ContentBlock file1 = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "file_excel", "data.xlsx"); + + ContentBlock file2 = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "file_pdf", "summary.pdf"); + + ChatCompletionResponse apiResponse = new ChatCompletionResponse("msg_123", "message", null, + List.of(textBlock, file1, file2), "claude-sonnet-4-5", null, null, null, null); + + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .keyValue("anthropic-response", apiResponse) + .build(); + + ChatResponse response = new ChatResponse(List.of(), metadata); + + List fileIds = SkillsResponseHelper.extractFileIds(response); + + assertThat(fileIds).hasSize(2); + assertThat(fileIds).containsExactly("file_excel", "file_pdf"); + } + + @Test + void shouldReturnNullContainerIdForNullResponse() { + String containerId = SkillsResponseHelper.extractContainerId(null); + assertThat(containerId).isNull(); + } + + @Test + void shouldReturnNullContainerIdForResponseWithoutMetadata() { + ChatResponse response = new ChatResponse(List.of()); + String containerId = SkillsResponseHelper.extractContainerId(response); + assertThat(containerId).isNull(); + } + + @Test + void shouldReturnNullContainerIdWhenNotPresent() { + ContentBlock textBlock = new ContentBlock("Response without container"); + ChatCompletionResponse apiResponse = new ChatCompletionResponse("msg_123", "message", null, List.of(textBlock), + "claude-sonnet-4-5", null, null, null, null); + + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .keyValue("anthropic-response", apiResponse) + .build(); + + ChatResponse response = new ChatResponse(List.of(), metadata); + + String containerId = SkillsResponseHelper.extractContainerId(response); + assertThat(containerId).isNull(); + } + + @Test + void shouldExtractContainerId() { + ContentBlock textBlock = new ContentBlock("Response with container"); + ChatCompletionResponse.Container container = new ChatCompletionResponse.Container("container_xyz789"); + ChatCompletionResponse apiResponse = new ChatCompletionResponse("msg_123", "message", null, List.of(textBlock), + "claude-sonnet-4-5", null, null, null, container); + + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .keyValue("anthropic-response", apiResponse) + .build(); + + ChatResponse response = new ChatResponse(List.of(), metadata); + + String containerId = SkillsResponseHelper.extractContainerId(response); + assertThat(containerId).isEqualTo("container_xyz789"); + } + + @Test + void shouldHandleMultipleFileBlocks() { + // Response with multiple file blocks in content + ContentBlock file1 = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "file_1", "file1.xlsx"); + ContentBlock file2 = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "file_2", "file2.pptx"); + ChatCompletionResponse apiResponse = new ChatCompletionResponse("msg_1", "message", null, List.of(file1, file2), + "claude-sonnet-4-5", null, null, null, null); + + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .keyValue("anthropic-response", apiResponse) + .build(); + + ChatResponse response = new ChatResponse(List.of(), metadata); + + List fileIds = SkillsResponseHelper.extractFileIds(response); + assertThat(fileIds).hasSize(2); + assertThat(fileIds).containsExactly("file_1", "file_2"); + } + + @Test + void shouldIgnoreFileBlocksWithoutFileId() { + // File block with null fileId should be ignored + ContentBlock invalidFileBlock = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, "file.xlsx"); + + ContentBlock validFileBlock = new ContentBlock(ContentBlock.Type.FILE, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, "file_valid", "valid.xlsx"); + + ChatCompletionResponse apiResponse = new ChatCompletionResponse("msg_123", "message", null, + List.of(invalidFileBlock, validFileBlock), "claude-sonnet-4-5", null, null, null, null); + + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .keyValue("anthropic-response", apiResponse) + .build(); + + ChatResponse response = new ChatResponse(List.of(), metadata); + + List fileIds = SkillsResponseHelper.extractFileIds(response); + + // Should only extract the valid file ID + assertThat(fileIds).hasSize(1); + assertThat(fileIds).containsExactly("file_valid"); + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiFilesTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiFilesTests.java new file mode 100644 index 00000000000..c67dbef629c --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiFilesTests.java @@ -0,0 +1,130 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic.api; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.anthropic.api.AnthropicApi.FileMetadata; +import org.springframework.ai.anthropic.api.AnthropicApi.FilesListResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for Files API models in {@link AnthropicApi}. + * + * @author Soby Chacko + * @since 2.0.0 + */ +class AnthropicApiFilesTests { + + @Test + void shouldCreateFileMetadataRecord() { + FileMetadata metadata = new FileMetadata("file_abc123", "sales_report.xlsx", 12345L, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "2025-11-02T12:00:00Z", + "2025-11-03T12:00:00Z"); + + assertThat(metadata.id()).isEqualTo("file_abc123"); + assertThat(metadata.filename()).isEqualTo("sales_report.xlsx"); + assertThat(metadata.size()).isEqualTo(12345L); + assertThat(metadata.mimeType()).isEqualTo("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + assertThat(metadata.createdAt()).isEqualTo("2025-11-02T12:00:00Z"); + assertThat(metadata.expiresAt()).isEqualTo("2025-11-03T12:00:00Z"); + } + + @Test + void shouldCreateFileMetadataWithNullFields() { + FileMetadata metadata = new FileMetadata("file_123", "test.xlsx", 100L, "application/xlsx", null, null); + + assertThat(metadata.id()).isEqualTo("file_123"); + assertThat(metadata.filename()).isEqualTo("test.xlsx"); + assertThat(metadata.size()).isEqualTo(100L); + assertThat(metadata.mimeType()).isEqualTo("application/xlsx"); + assertThat(metadata.createdAt()).isNull(); + assertThat(metadata.expiresAt()).isNull(); + } + + @Test + void shouldCreateFilesListResponse() { + List files = List.of( + new FileMetadata("file_1", "file1.xlsx", 100L, "application/xlsx", null, null), + new FileMetadata("file_2", "file2.pptx", 200L, "application/pptx", null, null)); + + FilesListResponse response = new FilesListResponse(files, true, "next_page_token"); + + assertThat(response.data()).hasSize(2); + assertThat(response.data().get(0).id()).isEqualTo("file_1"); + assertThat(response.data().get(1).id()).isEqualTo("file_2"); + assertThat(response.hasMore()).isTrue(); + assertThat(response.nextPage()).isEqualTo("next_page_token"); + } + + @Test + void shouldCreateFilesListResponseWithEmptyList() { + FilesListResponse response = new FilesListResponse(List.of(), false, null); + + assertThat(response.data()).isEmpty(); + assertThat(response.hasMore()).isFalse(); + assertThat(response.nextPage()).isNull(); + } + + @Test + void shouldCreateFilesListResponseWithMultiplePages() { + List page1 = List.of( + new FileMetadata("file_1", "file1.xlsx", 100L, "application/xlsx", "2025-11-02T10:00:00Z", + "2025-11-03T10:00:00Z"), + new FileMetadata("file_2", "file2.pptx", 200L, "application/pptx", "2025-11-02T11:00:00Z", + "2025-11-03T11:00:00Z")); + + FilesListResponse response = new FilesListResponse(page1, true, "page_2_token"); + + assertThat(response.data()).hasSize(2); + assertThat(response.hasMore()).isTrue(); + assertThat(response.nextPage()).isEqualTo("page_2_token"); + + // Verify metadata details + FileMetadata first = response.data().get(0); + assertThat(first.filename()).isEqualTo("file1.xlsx"); + assertThat(first.size()).isEqualTo(100L); + assertThat(first.createdAt()).isEqualTo("2025-11-02T10:00:00Z"); + assertThat(first.expiresAt()).isEqualTo("2025-11-03T10:00:00Z"); + } + + @Test + void shouldHandleDifferentFileTypes() { + List files = List.of( + new FileMetadata("file_1", "report.xlsx", 5000L, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", null, null), + new FileMetadata("file_2", "presentation.pptx", 15000L, + "application/vnd.openxmlformats-officedocument.presentationml.presentation", null, null), + new FileMetadata("file_3", "document.docx", 8000L, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", null, null), + new FileMetadata("file_4", "output.pdf", 25000L, "application/pdf", null, null)); + + FilesListResponse response = new FilesListResponse(files, false, null); + + assertThat(response.data()).hasSize(4); + assertThat(response.data()).extracting(FileMetadata::filename) + .containsExactly("report.xlsx", "presentation.pptx", "document.docx", "output.pdf"); + assertThat(response.data()).extracting(FileMetadata::mimeType) + .containsExactly("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/pdf"); + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiSkillTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiSkillTests.java new file mode 100644 index 00000000000..6e30303b2b2 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiSkillTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic.api; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicSkill; +import org.springframework.ai.anthropic.api.AnthropicApi.Skill; +import org.springframework.ai.anthropic.api.AnthropicApi.SkillContainer; +import org.springframework.ai.anthropic.api.AnthropicApi.SkillType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for Anthropic Skills API models. + * + * @author Soby Chacko + * @since 2.0.0 + */ +class AnthropicApiSkillTests { + + @Test + void shouldCreateAnthropicSkill() { + Skill skill = Skill.builder().type(SkillType.ANTHROPIC).skillId("xlsx").version("20251013").build(); + + assertThat(skill.type()).isEqualTo(SkillType.ANTHROPIC); + assertThat(skill.skillId()).isEqualTo("xlsx"); + assertThat(skill.version()).isEqualTo("20251013"); + } + + @Test + void shouldCreateCustomSkill() { + Skill skill = Skill.builder().type(SkillType.CUSTOM).skillId("custom-skill-id-12345").version("latest").build(); + + assertThat(skill.type()).isEqualTo(SkillType.CUSTOM); + assertThat(skill.skillId()).isEqualTo("custom-skill-id-12345"); + assertThat(skill.version()).isEqualTo("latest"); + } + + @Test + void shouldDefaultToLatestVersion() { + Skill skill = new Skill(SkillType.ANTHROPIC, "xlsx"); + assertThat(skill.version()).isEqualTo("latest"); + } + + @Test + void shouldCreateFromAnthropicSkillEnum() { + Skill skill = AnthropicSkill.XLSX.toSkill(); + + assertThat(skill.type()).isEqualTo(SkillType.ANTHROPIC); + assertThat(skill.skillId()).isEqualTo("xlsx"); + assertThat(skill.version()).isEqualTo("latest"); + } + + @Test + void shouldCreateFromAnthropicSkillEnumWithVersion() { + Skill skill = AnthropicSkill.PPTX.toSkill("20251013"); + + assertThat(skill.type()).isEqualTo(SkillType.ANTHROPIC); + assertThat(skill.skillId()).isEqualTo("pptx"); + assertThat(skill.version()).isEqualTo("20251013"); + } + + @Test + void shouldFailWhenSkillTypeIsNull() { + assertThatThrownBy(() -> Skill.builder().skillId("xlsx").build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Skill type cannot be null"); + } + + @Test + void shouldFailWhenSkillIdIsEmpty() { + assertThatThrownBy(() -> Skill.builder().type(SkillType.ANTHROPIC).skillId("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Skill ID cannot be empty"); + } + + @Test + void shouldCreateContainerWithSingleSkill() { + SkillContainer container = SkillContainer.builder().skill(SkillType.ANTHROPIC, "xlsx").build(); + + assertThat(container.skills()).hasSize(1); + assertThat(container.skills().get(0).skillId()).isEqualTo("xlsx"); + } + + @Test + void shouldCreateContainerWithMultipleSkills() { + SkillContainer container = SkillContainer.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .anthropicSkill(AnthropicSkill.PPTX) + .customSkill("company-guidelines") + .build(); + + assertThat(container.skills()).hasSize(3); + assertThat(container.skills()).extracting(Skill::skillId).containsExactly("xlsx", "pptx", "company-guidelines"); + } + + @Test + void shouldEnforceMaximum8Skills() { + SkillContainer.SkillContainerBuilder builder = SkillContainer.builder(); + + // Add 9 skills + for (int i = 0; i < 9; i++) { + builder.customSkill("skill-" + i); + } + + assertThatThrownBy(() -> builder.build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Maximum of 8 skills per request"); + } + + @Test + void shouldFailWithEmptySkillsList() { + assertThatThrownBy(() -> new SkillContainer(List.of())).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Skills list cannot be empty"); + } + + @Test + void shouldFailWithNullSkillsList() { + assertThatThrownBy(() -> new SkillContainer(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Skills list cannot be null"); + } + + @Test + void shouldAllowExactly8Skills() { + SkillContainer.SkillContainerBuilder builder = SkillContainer.builder(); + + for (int i = 0; i < 8; i++) { + builder.customSkill("skill-" + i); + } + + SkillContainer container = builder.build(); + assertThat(container.skills()).hasSize(8); + } + + @Test + void shouldGetSkillIdFromAnthropicSkillEnum() { + assertThat(AnthropicSkill.XLSX.getSkillId()).isEqualTo("xlsx"); + assertThat(AnthropicSkill.PPTX.getSkillId()).isEqualTo("pptx"); + assertThat(AnthropicSkill.DOCX.getSkillId()).isEqualTo("docx"); + assertThat(AnthropicSkill.PDF.getSkillId()).isEqualTo("pdf"); + } + + @Test + void shouldGetDescriptionFromAnthropicSkillEnum() { + assertThat(AnthropicSkill.XLSX.getDescription()).isEqualTo("Excel spreadsheet generation"); + assertThat(AnthropicSkill.PPTX.getDescription()).isEqualTo("PowerPoint presentation creation"); + assertThat(AnthropicSkill.DOCX.getDescription()).isEqualTo("Word document generation"); + assertThat(AnthropicSkill.PDF.getDescription()).isEqualTo("PDF document creation"); + } + + @Test + void shouldGetValueFromSkillTypeEnum() { + assertThat(SkillType.ANTHROPIC.getValue()).isEqualTo("anthropic"); + assertThat(SkillType.CUSTOM.getValue()).isEqualTo("custom"); + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/ChatCompletionRequestSkillsSerializationTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/ChatCompletionRequestSkillsSerializationTests.java new file mode 100644 index 00000000000..d9a84611e7d --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/ChatCompletionRequestSkillsSerializationTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.anthropic.api; + +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicMessage; +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicSkill; +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock; +import org.springframework.ai.anthropic.api.AnthropicApi.Role; +import org.springframework.ai.anthropic.api.AnthropicApi.Skill; +import org.springframework.ai.anthropic.api.AnthropicApi.SkillContainer; +import org.springframework.ai.anthropic.api.AnthropicApi.SkillType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ChatCompletionRequest} serialization with Skills. + * + * @author Soby Chacko + * @since 2.0.0 + */ +class ChatCompletionRequestSkillsSerializationTests { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldSerializeRequestWithSkills() throws Exception { + SkillContainer container = SkillContainer.builder().anthropicSkill(AnthropicSkill.XLSX).build(); + + AnthropicMessage message = new AnthropicMessage(List.of(new ContentBlock("Create a spreadsheet")), Role.USER); + + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model("claude-sonnet-4-5") + .messages(List.of(message)) + .maxTokens(1024) + .container(container) + .build(); + + String json = this.objectMapper.writeValueAsString(request); + + assertThat(json).contains("\"container\""); + assertThat(json).contains("\"skills\""); + assertThat(json).contains("\"type\":\"anthropic\""); + assertThat(json).contains("\"skill_id\":\"xlsx\""); + assertThat(json).contains("\"version\":\"latest\""); + } + + @Test + void shouldSerializeMultipleSkills() throws Exception { + SkillContainer container = SkillContainer.builder() + .anthropicSkill(AnthropicSkill.XLSX) + .anthropicSkill(AnthropicSkill.PPTX, "20251013") + .customSkill("custom-skill") + .build(); + + AnthropicMessage message = new AnthropicMessage(List.of(new ContentBlock("Create documents")), Role.USER); + + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model("claude-sonnet-4-5") + .messages(List.of(message)) + .maxTokens(1024) + .container(container) + .build(); + + String json = this.objectMapper.writeValueAsString(request); + + assertThat(json).contains("\"xlsx\""); + assertThat(json).contains("\"pptx\""); + assertThat(json).contains("\"custom-skill\""); + assertThat(json).contains("\"20251013\""); + } + + @Test + void shouldNotIncludeContainerWhenNull() throws Exception { + AnthropicMessage message = new AnthropicMessage(List.of(new ContentBlock("Simple message")), Role.USER); + + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model("claude-sonnet-4-5") + .messages(List.of(message)) + .maxTokens(1024) + .build(); + + String json = this.objectMapper.writeValueAsString(request); + + assertThat(json).doesNotContain("\"container\""); + } + + @Test + void shouldSerializeRequestWithSkillsUsingBuilderSkillsMethod() throws Exception { + List skills = List.of(new Skill(SkillType.ANTHROPIC, "docx", "latest"), + new Skill(SkillType.CUSTOM, "my-skill", "20251013")); + + AnthropicMessage message = new AnthropicMessage(List.of(new ContentBlock("Create documents")), Role.USER); + + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model("claude-sonnet-4-5") + .messages(List.of(message)) + .maxTokens(1024) + .skills(skills) + .build(); + + String json = this.objectMapper.writeValueAsString(request); + + assertThat(json).contains("\"container\""); + assertThat(json).contains("\"skills\""); + assertThat(json).contains("\"docx\""); + assertThat(json).contains("\"my-skill\""); + assertThat(json).contains("\"20251013\""); + } + + @Test + void shouldDeserializeRequestWithSkills() throws Exception { + String json = """ + { + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}] + } + ], + "max_tokens": 1024, + "container": { + "skills": [ + { + "type": "anthropic", + "skill_id": "xlsx", + "version": "latest" + } + ] + } + } + """; + + ChatCompletionRequest request = this.objectMapper.readValue(json, ChatCompletionRequest.class); + + assertThat(request.container()).isNotNull(); + assertThat(request.container().skills()).hasSize(1); + assertThat(request.container().skills().get(0).type()).isEqualTo(SkillType.ANTHROPIC); + assertThat(request.container().skills().get(0).skillId()).isEqualTo("xlsx"); + assertThat(request.container().skills().get(0).version()).isEqualTo("latest"); + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/StreamHelperTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/StreamHelperTests.java index 560dd8141b2..cfc44c9d224 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/StreamHelperTests.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/StreamHelperTests.java @@ -83,7 +83,7 @@ void testMessageStartEvent() { Usage usage = new Usage(10, 20, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); ChatCompletionResponse response = streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -104,7 +104,7 @@ void testContentBlockStartTextEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -130,7 +130,7 @@ void testContentBlockDeltaTextEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -155,7 +155,7 @@ void testMessageStopEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -177,7 +177,7 @@ void testMessageDeltaEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse initialMessage = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, - List.of(), "claude-3-5-sonnet", null, null, usage); + List.of(), "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, initialMessage); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -200,7 +200,7 @@ void testPingEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -220,7 +220,7 @@ void testToolUseAggregateEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -271,7 +271,7 @@ void testContentBlockStartThinkingEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -298,7 +298,7 @@ void testContentBlockDeltaThinkingEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -323,7 +323,7 @@ void testContentBlockDeltaSignatureEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -348,7 +348,7 @@ void testContentBlockStopEvent() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -367,7 +367,7 @@ void testUnsupportedContentBlockType() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -388,7 +388,7 @@ void testUnsupportedContentBlockDeltaType() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); @@ -409,7 +409,7 @@ void testToolUseAggregationWithEmptyToolContentBlocks() { Usage usage = new Usage(0, 0, null, null); ChatCompletionResponse message = new ChatCompletionResponse("msg-1", "message", Role.ASSISTANT, List.of(), - "claude-3-5-sonnet", null, null, usage); + "claude-3-5-sonnet", null, null, usage, null); MessageStartEvent startEvent = new MessageStartEvent(AnthropicApi.EventType.MESSAGE_START, message); streamHelper.eventToChatCompletionResponse(startEvent, contentBlockReference); diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc index 0e3e6a23853..5238e630bd9 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc @@ -1509,6 +1509,541 @@ Anthropic requires consistent citation settings across all documents in a reques You cannot mix citation-enabled and citation-disabled documents in the same request. ==== +== Skills + +Anthropic's https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview[Skills API] extends Claude's capabilities with specialized, pre-packaged abilities for document generation. +Skills enable Claude to create actual downloadable files - Excel spreadsheets, PowerPoint presentations, Word documents, and PDFs - rather than just describing what these documents might contain. + +Skills solve a fundamental limitation of traditional LLMs: + +* **Traditional Claude**: "Here's how your sales report would look..." (text description only) +* **With Skills**: Creates an actual `sales_report.xlsx` file you can download and open in Excel + +[NOTE] +==== +*Supported Models* + +Skills are supported on Claude Sonnet 4, Claude Sonnet 4.5, Claude Opus 4, and later models. + +*Requirements* + +* Skills require the code execution capability (automatically enabled by Spring AI) +* Maximum of 8 skills per request +* Generated files are available for download via the Files API for 24 hours +==== + +=== Pre-built Anthropic Skills + +Spring AI provides type-safe access to Anthropic's pre-built skills through the `AnthropicSkill` enum: + +[cols="2,3,4", stripes=even] +|==== +| Skill | Description | Generated File Type + +| `XLSX` +| Excel spreadsheet generation and manipulation +| `.xlsx` (Microsoft Excel) + +| `PPTX` +| PowerPoint presentation creation +| `.pptx` (Microsoft PowerPoint) + +| `DOCX` +| Word document generation +| `.docx` (Microsoft Word) + +| `PDF` +| PDF document creation +| `.pdf` (Portable Document Format) +|==== + +=== Basic Usage + +Enable skills by adding them to your `AnthropicChatOptions`: + +[source,java] +---- +ChatResponse response = chatModel.call( + new Prompt( + "Create an Excel spreadsheet with Q1 2025 sales data. " + + "Include columns for Month, Revenue, and Expenses with 3 rows of sample data.", + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(4096) + .anthropicSkill(AnthropicApi.AnthropicSkill.XLSX) + .build() + ) +); + +// Claude will generate an actual Excel file +String responseText = response.getResult().getOutput().getText(); +System.out.println(responseText); +// Output: "I've created an Excel spreadsheet with your Q1 2025 sales data..." +---- + +=== Multiple Skills + +You can enable multiple skills in a single request (up to 8): + +[source,java] +---- +ChatResponse response = chatModel.call( + new Prompt( + "Create a sales report with both an Excel file containing the raw data " + + "and a PowerPoint presentation summarizing the key findings.", + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(8192) + .anthropicSkill(AnthropicApi.AnthropicSkill.XLSX) + .anthropicSkill(AnthropicApi.AnthropicSkill.PPTX) + .build() + ) +); +---- + +=== Using SkillContainer for Advanced Configuration + +For more control, use `SkillContainer` directly: + +[source,java] +---- +AnthropicApi.SkillContainer container = AnthropicApi.SkillContainer.builder() + .anthropicSkill(AnthropicApi.AnthropicSkill.XLSX) + .anthropicSkill(AnthropicApi.AnthropicSkill.PPTX, "20251013") // Specific version + .build(); + +ChatResponse response = chatModel.call( + new Prompt( + "Generate the quarterly report", + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(4096) + .skillContainer(container) + .build() + ) +); +---- + +=== Using ChatClient Fluent API + +Skills work seamlessly with the ChatClient fluent API: + +[source,java] +---- +String response = ChatClient.create(chatModel) + .prompt() + .user("Create a PowerPoint presentation about Spring AI with 3 slides: " + + "Title, Key Features, and Getting Started") + .options(AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(4096) + .anthropicSkill(AnthropicApi.AnthropicSkill.PPTX) + .build()) + .call() + .content(); +---- + +=== Streaming with Skills + +Skills work with streaming responses: + +[source,java] +---- +Flux responseFlux = chatModel.stream( + new Prompt( + "Create a Word document explaining machine learning concepts", + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(4096) + .anthropicSkill(AnthropicApi.AnthropicSkill.DOCX) + .build() + ) +); + +responseFlux.subscribe(response -> { + String content = response.getResult().getOutput().getText(); + System.out.print(content); +}); +---- + +=== Downloading Generated Files + +When Claude generates files using Skills, the response contains file IDs that can be used to download the actual files via the Files API. +Spring AI provides the `SkillsResponseHelper` utility class for extracting file IDs and downloading files. + +==== Extracting File IDs + +[source,java] +---- +import org.springframework.ai.anthropic.SkillsResponseHelper; + +ChatResponse response = chatModel.call(prompt); + +// Extract all file IDs from the response +List fileIds = SkillsResponseHelper.extractFileIds(response); + +for (String fileId : fileIds) { + System.out.println("Generated file ID: " + fileId); +} +---- + +==== Getting File Metadata + +Before downloading, you can retrieve file metadata: + +[source,java] +---- +@Autowired +private AnthropicApi anthropicApi; + +// Get metadata for a specific file +String fileId = fileIds.get(0); +AnthropicApi.FileMetadata metadata = anthropicApi.getFileMetadata(fileId); + +System.out.println("Filename: " + metadata.filename()); // e.g., "sales_report.xlsx" +System.out.println("Size: " + metadata.size() + " bytes"); // e.g., 5082 +System.out.println("MIME Type: " + metadata.mimeType()); // e.g., "application/vnd..." +---- + +==== Downloading File Content + +[source,java] +---- +// Download file content as bytes +byte[] fileContent = anthropicApi.downloadFile(fileId); + +// Save to local file system +Path outputPath = Path.of("downloads", metadata.filename()); +Files.write(outputPath, fileContent); + +System.out.println("Saved file to: " + outputPath); +---- + +==== Convenience Method: Download All Files + +The `SkillsResponseHelper` provides a convenience method to download all generated files at once: + +[source,java] +---- +// Download all files to a target directory +Path targetDir = Path.of("generated-files"); +Files.createDirectories(targetDir); + +List savedFiles = SkillsResponseHelper.downloadAllFiles(response, anthropicApi, targetDir); + +for (Path file : savedFiles) { + System.out.println("Downloaded: " + file.getFileName() + + " (" + Files.size(file) + " bytes)"); +} +---- + +==== Complete File Download Example + +Here's a complete example showing Skills usage with file download: + +[source,java] +---- +@Service +public class DocumentGenerationService { + + private final AnthropicChatModel chatModel; + private final AnthropicApi anthropicApi; + + public DocumentGenerationService(AnthropicChatModel chatModel, AnthropicApi anthropicApi) { + this.chatModel = chatModel; + this.anthropicApi = anthropicApi; + } + + public Path generateSalesReport(String quarter, Path outputDir) throws IOException { + // Generate Excel report using Skills + ChatResponse response = chatModel.call( + new Prompt( + "Create an Excel spreadsheet with " + quarter + " sales data. " + + "Include Month, Revenue, Expenses, and Profit columns.", + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(4096) + .anthropicSkill(AnthropicApi.AnthropicSkill.XLSX) + .build() + ) + ); + + // Extract file IDs from the response + List fileIds = SkillsResponseHelper.extractFileIds(response); + + if (fileIds.isEmpty()) { + throw new RuntimeException("No file was generated"); + } + + // Download the generated file + String fileId = fileIds.get(0); + AnthropicApi.FileMetadata metadata = anthropicApi.getFileMetadata(fileId); + byte[] content = anthropicApi.downloadFile(fileId); + + // Save to output directory + Path outputPath = outputDir.resolve(metadata.filename()); + Files.write(outputPath, content); + + return outputPath; + } +} +---- + +=== Files API Operations + +The `AnthropicApi` provides direct access to the Files API: + +[cols="2,4", stripes=even] +|==== +| Method | Description + +| `getFileMetadata(fileId)` +| Get metadata including filename, size, MIME type, and expiration time + +| `downloadFile(fileId)` +| Download file content as byte array + +| `listFiles(limit, page)` +| List files with pagination support + +| `deleteFile(fileId)` +| Delete a file immediately (files auto-expire after 24 hours) +|==== + +==== Listing Files + +[source,java] +---- +// List files with pagination +AnthropicApi.FilesListResponse files = anthropicApi.listFiles(20, null); + +for (AnthropicApi.FileMetadata file : files.data()) { + System.out.println(file.id() + ": " + file.filename()); +} + +// Check for more pages +if (files.hasMore()) { + AnthropicApi.FilesListResponse nextPage = anthropicApi.listFiles(20, files.nextPage()); + // Process next page... +} +---- + +==== Extracting Container ID + +For multi-turn conversations with Skills, you may need to extract the container ID: + +[source,java] +---- +String containerId = SkillsResponseHelper.extractContainerId(response); + +if (containerId != null) { + System.out.println("Container ID for reuse: " + containerId); +} +---- + +=== Best Practices + +1. **Use appropriate models**: Skills work best with Claude Sonnet 4 and later models. Ensure you're using a supported model. + +2. **Set sufficient max tokens**: Document generation can require significant tokens. Use `maxTokens(4096)` or higher for complex documents. + +3. **Be specific in prompts**: Provide clear, detailed instructions about document structure, content, and formatting. + +4. **Handle file downloads promptly**: Generated files expire after 24 hours. Download files soon after generation. + +5. **Check for file IDs**: Always verify that file IDs were returned before attempting downloads. Some prompts may result in text responses without file generation. + +6. **Use defensive error handling**: Wrap file operations in try-catch blocks to handle network issues or expired files gracefully. + +[source,java] +---- +List fileIds = SkillsResponseHelper.extractFileIds(response); + +if (fileIds.isEmpty()) { + // Claude may have responded with text instead of generating a file + String text = response.getResult().getOutput().getText(); + log.warn("No files generated. Response: {}", text); + return; +} + +try { + byte[] content = anthropicApi.downloadFile(fileIds.get(0)); + // Process file... +} catch (Exception e) { + log.error("Failed to download file: {}", e.getMessage()); +} +---- + +=== Real-World Use Cases + +==== Automated Report Generation + +Generate formatted business reports from data: + +[source,java] +---- +@Service +public class ReportService { + + private final AnthropicChatModel chatModel; + private final AnthropicApi anthropicApi; + + public byte[] generateMonthlyReport(SalesData data) throws IOException { + String prompt = String.format( + "Create a PowerPoint presentation summarizing monthly sales performance. " + + "Total Revenue: $%,.2f, Total Expenses: $%,.2f, Net Profit: $%,.2f. " + + "Include charts and key insights. Create 5 slides: " + + "1) Title, 2) Revenue Overview, 3) Expense Breakdown, " + + "4) Profit Analysis, 5) Recommendations.", + data.revenue(), data.expenses(), data.profit() + ); + + ChatResponse response = chatModel.call( + new Prompt(prompt, + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(8192) + .anthropicSkill(AnthropicApi.AnthropicSkill.PPTX) + .build() + ) + ); + + List fileIds = SkillsResponseHelper.extractFileIds(response); + return anthropicApi.downloadFile(fileIds.get(0)); + } +} +---- + +==== Data Export Service + +Export structured data to Excel format: + +[source,java] +---- +@RestController +public class ExportController { + + private final AnthropicChatModel chatModel; + private final AnthropicApi anthropicApi; + private final CustomerRepository customerRepository; + + @GetMapping("/export/customers") + public ResponseEntity exportCustomers() throws IOException { + List customers = customerRepository.findAll(); + + String dataDescription = customers.stream() + .map(c -> String.format("%s, %s, %s", c.name(), c.email(), c.tier())) + .collect(Collectors.joining("\n")); + + ChatResponse response = chatModel.call( + new Prompt( + "Create an Excel spreadsheet with customer data. " + + "Columns: Name, Email, Tier. Format the header row with bold text. " + + "Data:\n" + dataDescription, + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(4096) + .anthropicSkill(AnthropicApi.AnthropicSkill.XLSX) + .build() + ) + ); + + List fileIds = SkillsResponseHelper.extractFileIds(response); + byte[] content = anthropicApi.downloadFile(fileIds.get(0)); + AnthropicApi.FileMetadata metadata = anthropicApi.getFileMetadata(fileIds.get(0)); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + metadata.filename() + "\"") + .contentType(MediaType.parseMediaType(metadata.mimeType())) + .body(content); + } +} +---- + +==== Multi-Format Document Generation + +Generate multiple document formats from a single request: + +[source,java] +---- +public Map generateProjectDocumentation(ProjectInfo project) throws IOException { + ChatResponse response = chatModel.call( + new Prompt( + "Create project documentation for: " + project.name() + "\n" + + "Description: " + project.description() + "\n\n" + + "Generate:\n" + + "1. An Excel file with the project timeline and milestones\n" + + "2. A PowerPoint overview presentation (3-5 slides)\n" + + "3. A Word document with detailed specifications", + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(16384) + .anthropicSkill(AnthropicApi.AnthropicSkill.XLSX) + .anthropicSkill(AnthropicApi.AnthropicSkill.PPTX) + .anthropicSkill(AnthropicApi.AnthropicSkill.DOCX) + .build() + ) + ); + + Map documents = new HashMap<>(); + List fileIds = SkillsResponseHelper.extractFileIds(response); + + for (String fileId : fileIds) { + AnthropicApi.FileMetadata metadata = anthropicApi.getFileMetadata(fileId); + byte[] content = anthropicApi.downloadFile(fileId); + documents.put(metadata.filename(), content); + } + + return documents; +} +---- + +=== Combining Skills with Other Features + +Skills can be combined with other Anthropic features like Prompt Caching: + +[source,java] +---- +ChatResponse response = chatModel.call( + new Prompt( + List.of( + new SystemMessage("You are an expert data analyst and document creator..."), + new UserMessage("Create a financial summary spreadsheet") + ), + AnthropicChatOptions.builder() + .model("claude-sonnet-4-5") + .maxTokens(4096) + .anthropicSkill(AnthropicApi.AnthropicSkill.XLSX) + .cacheOptions(AnthropicCacheOptions.builder() + .strategy(AnthropicCacheStrategy.SYSTEM_ONLY) + .build()) + .build() + ) +); +---- + +[NOTE] +==== +*About Custom Skills* + +In addition to the pre-built skills documented above, Anthropic supports custom skills that organizations can create for specialized document templates, formatting rules, or domain-specific behaviors. + +**Important:** Custom skills must be created and uploaded through Anthropic's platform (Console or API) before they can be used. +Spring AI currently supports **using** custom skills once they are set up, but does not provide APIs for creating or managing custom skills. + +If you have custom skills configured in your Anthropic workspace, you can reference them by their skill ID: + +[source,java] +---- +AnthropicApi.SkillContainer container = AnthropicApi.SkillContainer.builder() + .customSkill("skill_abc123") // Your custom skill ID from Anthropic + .build(); +---- + +Refer to the https://platform.claude.com/docs/en/build-with-claude/skills-guide[Anthropic Skills API documentation] for details on creating and managing custom skills in your workspace. +==== + == Sample Controller https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-starter-model-anthropic` to your pom (or gradle) dependencies. @@ -1636,4 +2171,4 @@ Flux response = this.anthropicApi Follow the https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java[AnthropicApi.java]'s JavaDoc for further information. === Low-level API Examples -* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/chat/api/AnthropicApiIT.java[AnthropicApiIT.java] test provides some general examples how to use the lightweight library. \ No newline at end of file +* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/chat/api/AnthropicApiIT.java[AnthropicApiIT.java] test provides some general examples how to use the lightweight library.