From 48c4e58e39d175417214f207db87bc563b3dacc2 Mon Sep 17 00:00:00 2001 From: Mark Pollack Date: Wed, 3 Dec 2025 00:36:32 -0500 Subject: [PATCH] Add Claude Skills integration with Files API support Integrate Anthropic's pre-built Skills for document generation. Skills enable Claude to create actual downloadable files rather than just describing them. Supported skills: - XLSX: Generate Excel spreadsheets - PPTX: Create PowerPoint presentations - DOCX: Generate Word documents - PDF: Create PDF files Example usage: AnthropicChatOptions.builder() .anthropicSkill(AnthropicSkill.XLSX) .build() Generated files can be downloaded via the Files API using SkillsResponseHelper to extract file IDs from responses. Breaking change: - ContentBlock.content type changed from String to Object to support nested JSON structures in Skills responses Signed-off-by: Mark Pollack --- .../ai/anthropic/AnthropicChatModel.java | 62 ++- .../ai/anthropic/AnthropicChatOptions.java | 97 ++++- .../ai/anthropic/SkillsResponseHelper.java | 191 +++++++++ .../ai/anthropic/api/AnthropicApi.java | 385 +++++++++++++++++- .../ai/anthropic/api/StreamHelper.java | 15 +- .../AnthropicChatModelSkillsTests.java | 305 ++++++++++++++ .../ai/anthropic/api/StreamHelperTests.java | 28 +- 7 files changed, 1044 insertions(+), 39 deletions(-) create mode 100644 models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/SkillsResponseHelper.java create mode 100644 models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelSkillsTests.java 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..264a563d966 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 @@ -583,6 +583,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 +599,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()); @@ -649,6 +658,56 @@ ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { requestOptions.setHttpHeaders(headers); } + // 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, null)); + request = ChatCompletionRequest.from(request).tools(existingTools).build(); + } + + // Skills require three beta headers: skills, code-execution, and files-api + Map headers = requestOptions != null ? new HashMap<>(requestOptions.getHttpHeaders()) + : new HashMap<>(); + String requiredBetas = AnthropicApi.BETA_SKILLS + "," + AnthropicApi.BETA_CODE_EXECUTION + "," + + AnthropicApi.BETA_FILES_API; + String existingBeta = headers.get("anthropic-beta"); + if (existingBeta != null) { + 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); + } + if (requestOptions != null) { + requestOptions.setHttpHeaders(headers); + } + } + return request; } @@ -845,7 +904,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..cf64dd63589 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 @@ -115,6 +115,15 @@ public void setCacheOptions(AnthropicCacheOptions cacheOptions) { @JsonIgnore private Map httpHeaders = new HashMap<>(); + /** + * 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; + // @formatter:on public static Builder builder() { @@ -141,9 +150,18 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions) .cacheOptions(fromOptions.getCacheOptions()) .citationDocuments(fromOptions.getCitationDocuments() != null ? new ArrayList<>(fromOptions.getCitationDocuments()) : null) + .skillContainer(fromOptions.getSkillContainer()) .build(); } + public AnthropicApi.SkillContainer getSkillContainer() { + return this.skillContainer; + } + + public void setSkillContainer(AnthropicApi.SkillContainer skillContainer) { + this.skillContainer = skillContainer; + } + @Override public String getModel() { return this.model; @@ -351,7 +369,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 +378,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 +520,80 @@ 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). + * @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 bd64580f8de..95be58bd7e2 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 @@ -90,6 +90,16 @@ public static Builder builder() { 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 FILES_PATH = "/v1/files"; + private static final String HEADER_X_API_KEY = "x-api-key"; private static final String HEADER_ANTHROPIC_VERSION = "anthropic-version"; @@ -255,6 +265,58 @@ 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 ID of the file (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 by ID. + * @param fileId The ID of the file (format: file_*) + * @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); + } + private void addDefaultHeadersIfMissing(HttpHeaders headers) { if (!headers.containsKey(HEADER_X_API_KEY)) { String apiKeyValue = this.apiKey.getValue(); @@ -460,6 +522,240 @@ public enum EventType { } + /** + * Types of Claude Skills. + */ + public enum SkillType { + + /** + * Pre-built Anthropic skills (xlsx, pptx, docx, pdf). + */ + @JsonProperty("anthropic") + ANTHROPIC("anthropic"), + + /** + * Custom user-created skills. + */ + @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 { + + /** + * Excel spreadsheet generation skill. + */ + XLSX("xlsx", "Creates Excel spreadsheets"), + + /** + * PowerPoint presentation generation skill. + */ + PPTX("pptx", "Creates PowerPoint presentations"), + + /** + * Word document generation skill. + */ + DOCX("docx", "Creates Word documents"), + + /** + * PDF document generation skill. + */ + PDF("pdf", "Creates PDF documents"); + + 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 Skill type - ANTHROPIC for pre-built skills or CUSTOM for user-created. + * @param skillId Skill identifier - short name for Anthropic skills (e.g., "xlsx", + * "pptx") or custom skill ID. + * @param version Optional version string (e.g., "latest", "20251013"). Defaults to + * "latest". + */ + @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 + */ + @JsonInclude(Include.NON_NULL) + public record SkillContainer(@JsonProperty("skills") List skills) { + + public SkillContainer { + Assert.notNull(skills, "Skills list cannot be null"); + Assert.isTrue(skills.size() <= 8, "Maximum of 8 skills per request"); + } + + 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(new Skill(SkillType.CUSTOM, skillId)); + } + + public SkillContainerBuilder customSkill(String skillId, String version) { + return skill(new 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)); + } + + } + + } + + /** + * File metadata returned from the Files API. + * + * @param id Unique file identifier (format: file_*) + * @param filename Original filename + * @param size File size in bytes + * @param mimeType MIME type of the file + * @param createdAt Creation timestamp + * @param expiresAt Expiration timestamp + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + 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) { + } + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) @JsonSubTypes({ @JsonSubTypes.Type(value = ContentBlockStartEvent.class, name = "content_block_start"), @@ -540,18 +836,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() { @@ -629,6 +927,8 @@ public static final class ChatCompletionRequestBuilder { private ChatCompletionRequest.ThinkingConfig thinking; + private SkillContainer container; + private ChatCompletionRequestBuilder() { } @@ -646,6 +946,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) { @@ -723,10 +1024,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); } } @@ -857,7 +1170,10 @@ 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 content block from Skills + @JsonProperty("file_id") String fileId ) { // @formatter:on @@ -876,7 +1192,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); } /** @@ -884,7 +1201,7 @@ 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, + this(Type.IMAGE, source, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); } @@ -893,11 +1210,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); } 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); } // Tool result @@ -908,7 +1227,7 @@ public ContentBlock(String text, CacheControl cache) { * @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, + this(type, null, null, null, null, null, null, toolUseId, content, null, null, null, null, null, null, null, null); } @@ -920,7 +1239,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); } // Tool use input JSON delta streaming @@ -932,7 +1252,7 @@ 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); } /** @@ -946,7 +1266,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); } public static ContentBlockBuilder from(ContentBlock contentBlock) { @@ -1026,7 +1346,13 @@ public enum Type { * Redacted Thinking message. */ @JsonProperty("redacted_thinking") - REDACTED_THINKING("redacted_thinking"); + REDACTED_THINKING("redacted_thinking"), + + /** + * File content block from Skills. + */ + @JsonProperty("file") + FILE("file"); public final String value; @@ -1116,6 +1442,8 @@ public static class ContentBlockBuilder { private Object citations; + private String fileId; + public ContentBlockBuilder(ContentBlock contentBlock) { this.type = contentBlock.type; this.source = contentBlock.source; @@ -1133,6 +1461,7 @@ public ContentBlockBuilder(ContentBlock contentBlock) { this.title = contentBlock.title; this.context = contentBlock.context; this.citations = contentBlock.citations; + this.fileId = contentBlock.fileId; } public ContentBlockBuilder type(Type type) { @@ -1200,10 +1529,15 @@ public ContentBlockBuilder cacheControl(CacheControl cacheControl) { return this; } + public ContentBlockBuilder fileId(String fileId) { + this.fileId = fileId; + return this; + } + 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, this.fileId); } } @@ -1216,6 +1550,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. @@ -1224,6 +1560,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, @@ -1231,10 +1568,10 @@ 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); } } @@ -1387,8 +1724,20 @@ 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 in multi-turn conversations. + * + * @param id Container identifier (format: container_*) + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + 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..d3ba09c8f10 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); contentBlockReference.get().withType(event.type().name()).withContent(List.of(cb)); } else { @@ -182,12 +182,12 @@ 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); 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); contentBlockReference.get().withType(event.type().name()).withContent(List.of(cb)); } else { @@ -261,6 +261,8 @@ public static class ChatCompletionResponseBuilder { private Usage usage; + private ChatCompletionResponse.Container container; + public ChatCompletionResponseBuilder() { } @@ -304,9 +306,14 @@ public ChatCompletionResponseBuilder withUsage(Usage usage) { return this; } + public ChatCompletionResponseBuilder withContainer(ChatCompletionResponse.Container container) { + this.container = container; + return this; + } + 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, this.container); } } 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..70592bb292b --- /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 1.1.1 + */ +@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/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);