diff --git a/agentscope-core/pom.xml b/agentscope-core/pom.xml index 54bf500a7..9a8cf046c 100644 --- a/agentscope-core/pom.xml +++ b/agentscope-core/pom.xml @@ -113,12 +113,7 @@ google-genai - - - com.anthropic - anthropic-java - - + io.modelcontextprotocol.sdk diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicBaseFormatter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicBaseFormatter.java index f946d16a3..d40d76785 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicBaseFormatter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicBaseFormatter.java @@ -15,92 +15,89 @@ */ package io.agentscope.core.formatter.anthropic; -import com.anthropic.models.messages.MessageCreateParams; -import com.anthropic.models.messages.MessageParam; import io.agentscope.core.formatter.AbstractBaseFormatter; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; +import io.agentscope.core.formatter.anthropic.dto.AnthropicRequest; +import io.agentscope.core.formatter.anthropic.dto.AnthropicResponse; import io.agentscope.core.message.Msg; import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; import io.agentscope.core.model.ToolSchema; import java.util.List; /** - * Abstract base formatter for Anthropic API with shared logic for handling Anthropic-specific + * Abstract base formatter for Anthropic API with shared logic for handling + * Anthropic-specific * requirements. * - *

This class handles: + *

+ * This class handles: * *

*/ public abstract class AnthropicBaseFormatter - extends AbstractBaseFormatter { + extends AbstractBaseFormatter { protected final AnthropicMessageConverter messageConverter; - /** Thread-local storage for generation options (passed from applyOptions to applyTools). */ - private final ThreadLocal currentOptions = new ThreadLocal<>(); - protected AnthropicBaseFormatter() { this.messageConverter = new AnthropicMessageConverter(this::convertToolResultToString); } + protected AnthropicBaseFormatter(AnthropicMessageConverter messageConverter) { + this.messageConverter = messageConverter; + } + /** * Apply generation options to Anthropic request parameters. * - * @param paramsBuilder Anthropic request parameters builder - * @param options Generation options to apply + * @param request Anthropic request + * @param options Generation options to apply * @param defaultOptions Default options to use if options parameter is null */ @Override public void applyOptions( - MessageCreateParams.Builder paramsBuilder, - GenerateOptions options, - GenerateOptions defaultOptions) { - // Save options for applyTools - currentOptions.set(options); - - // Apply other options - AnthropicToolsHelper.applyOptions(paramsBuilder, options, defaultOptions); + AnthropicRequest request, GenerateOptions options, GenerateOptions defaultOptions) { + AnthropicToolsHelper.applyOptions(request, options, defaultOptions); } /** - * Apply tool schemas to Anthropic request parameters. This method uses the options saved from - * applyOptions to apply tool choice configuration. + * Apply tool schemas to Anthropic request parameters. * - * @param paramsBuilder Anthropic request parameters builder - * @param tools List of tool schemas to apply (may be null or empty) + * @param request Anthropic request + * @param tools List of tool schemas to apply (may be null or empty) */ @Override - public void applyTools(MessageCreateParams.Builder paramsBuilder, List tools) { - if (tools == null || tools.isEmpty()) { - currentOptions.remove(); - return; - } - - // Use saved options to apply tools with tool choice - GenerateOptions options = currentOptions.get(); - AnthropicToolsHelper.applyTools(paramsBuilder, tools, options); + public void applyTools(AnthropicRequest request, List tools) { + AnthropicToolsHelper.applyTools(request, tools); + } - // Clean up thread-local storage - currentOptions.remove(); + @Override + public void applyToolChoice(AnthropicRequest request, ToolChoice toolChoice) { + AnthropicToolsHelper.applyToolChoice(request, toolChoice); } /** - * Extract and apply system message if present. Anthropic API requires system message to be set + * Extract and apply system message if present. Anthropic API requires system + * message to be set * via the system parameter, not as a message. * - *

This method is called by Model to extract the first system message from the messages list + *

+ * This method is called by Model to extract the first system message from the + * messages list * and apply it to the system parameter. * - * @param paramsBuilder Anthropic request parameters builder + * @param request Anthropic request * @param messages All messages including potential system message */ - public void applySystemMessage(MessageCreateParams.Builder paramsBuilder, List messages) { + public void applySystemMessage(AnthropicRequest request, List messages) { String systemMessage = messageConverter.extractSystemMessage(messages); if (systemMessage != null && !systemMessage.isEmpty()) { - paramsBuilder.system(systemMessage); + request.setSystem(systemMessage); } } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatter.java index c3d7abc72..ff1a5999e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatter.java @@ -15,38 +15,238 @@ */ package io.agentscope.core.formatter.anthropic; -import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.MessageParam; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; +import io.agentscope.core.formatter.anthropic.dto.AnthropicResponse; +import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.ChatResponse; import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * Formatter for Anthropic Messages API. Converts between AgentScope Msg objects and Anthropic SDK - * types. + * Formatter for Anthropic Messages API. Converts between AgentScope Msg objects + * and Anthropic DTO types. * *

Important: Anthropic API has special requirements: - * *

    *
  • Only the first message can be a system message (handled via system parameter) *
  • Tool results must be in separate user messages *
  • Supports thinking blocks natively (extended thinking feature) + *
  • Automatic multi-agent conversation handling for MsgHub scenarios *
+ * + *

Multi-Agent Detection: This formatter automatically detects multi-agent scenarios + * (e.g., MsgHub conversations) by checking for multiple messages with different names but + * the same role. When detected, it uses {@link AnthropicConversationMerger} to consolidate the + * conversation into a format compatible with Anthropic's API. */ public class AnthropicChatFormatter extends AnthropicBaseFormatter { + private static final Logger log = LoggerFactory.getLogger(AnthropicChatFormatter.class); + + private static final String DEFAULT_CONVERSATION_HISTORY_PROMPT = + "# Conversation History\n" + + "The content between tags contains your conversation" + + " history\n"; + + private final AnthropicMediaConverter mediaConverter; + + public AnthropicChatFormatter() { + super(); + this.mediaConverter = new AnthropicMediaConverter(); + } + + public AnthropicChatFormatter(AnthropicMessageConverter messageConverter) { + super(messageConverter); + this.mediaConverter = new AnthropicMediaConverter(); + } + @Override - public List doFormat(List msgs) { + public List doFormat(List msgs) { + // Detect multi-agent scenario (multiple messages with different names but same role) + boolean isMultiAgent = isMultiAgentConversation(msgs); + log.debug( + "doFormat: message count={}, isMultiAgent={}", + msgs != null ? msgs.size() : 0, + isMultiAgent); + + if (isMultiAgent) { + log.info("Detected multi-agent conversation, using conversation merger"); + return formatMultiAgentConversation(msgs); + } + + // Single-agent or simple conversation - use standard formatting + log.debug("Using standard formatting for single-agent conversation"); return messageConverter.convert(msgs); } - @Override - public ChatResponse parseResponse(Object response, Instant startTime) { - if (response instanceof Message message) { - return AnthropicResponseParser.parseMessage(message, startTime); - } else { - throw new IllegalArgumentException("Unsupported response type: " + response.getClass()); + /** + * Detects if the message list represents a multi-agent conversation. + * + *

A multi-agent conversation is detected when: + *

    + *
  • There are at least 2 ASSISTANT role messages with different names
  • + *
  • OR there are multiple ASSISTANT messages that would create consecutive + * messages with the same role
  • + *
+ * + * @param msgs List of messages to check + * @return true if this appears to be a multi-agent conversation + */ + private boolean isMultiAgentConversation(List msgs) { + if (msgs == null || msgs.size() < 2) { + log.debug( + "isMultiAgentConversation: too few messages (count={})", + msgs != null ? msgs.size() : 0); + return false; + } + + Set assistantNames = new HashSet<>(); + MsgRole lastRole = null; + boolean hasConsecutiveAssistant = false; + boolean hasSystemNamedUserMessage = false; + + for (int i = 0; i < msgs.size(); i++) { + Msg msg = msgs.get(i); + MsgRole currentRole = msg.getRole(); + String msgName = msg.getName(); + + log.trace("Message {}: role={}, name={}", i, currentRole, msgName); + + // Check for consecutive ASSISTANT messages (without tool calls in between) + if (lastRole == MsgRole.ASSISTANT && currentRole == MsgRole.ASSISTANT) { + hasConsecutiveAssistant = true; + log.debug("Found consecutive ASSISTANT messages at index {}", i); + } + + // Check if USER message has name="system" (indicates MsgHub announcement) + if (currentRole == MsgRole.USER && "system".equals(msgName)) { + hasSystemNamedUserMessage = true; + log.debug("Found USER message with name='system' (MsgHub announcement)"); + } + + // Collect ASSISTANT message names + if (currentRole == MsgRole.ASSISTANT && msgName != null) { + assistantNames.add(msgName); + } + + lastRole = currentRole; } + + // Multi-agent if: + // 1. Multiple assistant names (different agents), OR + // 2. Consecutive assistant messages, OR + // 3. System-named USER message (MsgHub announcement) + boolean result = + assistantNames.size() > 1 || hasConsecutiveAssistant || hasSystemNamedUserMessage; + log.debug( + "isMultiAgentConversation: assistantNames={}, hasConsecutive={}, " + + "hasSystemNamedUserMessage={}, result={}", + assistantNames, + hasConsecutiveAssistant, + hasSystemNamedUserMessage, + result); + return result; + } + + /** + * Formats a multi-agent conversation using the conversation merger. + * This consolidates multiple agent messages into a format compatible with Anthropic's API. + * + * @param msgs List of messages in the multi-agent conversation + * @return List of Anthropic-formatted messages + */ + private List formatMultiAgentConversation(List msgs) { + log.debug("formatMultiAgentConversation: processing {} messages", msgs.size()); + + // Separate messages into groups: SYSTEM, TOOL_SEQUENCE, AGENT_CONVERSATION + List result = new ArrayList<>(); + List systemMsgs = new ArrayList<>(); + List toolSequence = new ArrayList<>(); + List agentConversation = new ArrayList<>(); + + for (Msg msg : msgs) { + MsgRole role = msg.getRole(); + + if (role == MsgRole.SYSTEM) { + systemMsgs.add(msg); + } else if (msg.hasContentBlocks(ToolUseBlock.class) + || msg.hasContentBlocks(ToolResultBlock.class)) { + // Tool-related messages: use standard converter + toolSequence.add(msg); + } else { + // Regular conversation messages (USER, ASSISTANT without tools) + agentConversation.add(msg); + } + } + + // Add system messages using standard converter + for (Msg sysMsg : systemMsgs) { + List converted = messageConverter.convert(List.of(sysMsg)); + result.addAll(converted); + } + + // Add tool sequence using standard converter + if (!toolSequence.isEmpty()) { + List converted = messageConverter.convert(toolSequence); + result.addAll(converted); + log.debug("Added {} tool messages using standard converter", toolSequence.size()); + } + + // Merge agent conversation into a single user message + if (!agentConversation.isEmpty()) { + log.debug("Merging {} agent conversation messages", agentConversation.size()); + + List mergedContent = + AnthropicConversationMerger.mergeConversation( + agentConversation, DEFAULT_CONVERSATION_HISTORY_PROMPT); + + List contentBlocks = new ArrayList<>(); + + for (Object item : mergedContent) { + if (item instanceof String text) { + contentBlocks.add(AnthropicContent.text(text)); + log.trace("Added text content block (length: {})", text.length()); + } else if (item instanceof ImageBlock ib) { + try { + AnthropicContent.ImageSource imageSource = + mediaConverter.convertImageBlock(ib); + contentBlocks.add( + AnthropicContent.image( + imageSource.getMediaType(), imageSource.getData())); + log.trace("Added image content block"); + } catch (Exception e) { + log.warn("Failed to convert image block: {}", e.getMessage()); + contentBlocks.add(AnthropicContent.text("[Image - conversion failed]")); + } + } + } + + if (!contentBlocks.isEmpty()) { + AnthropicMessage mergedMessage = new AnthropicMessage("user", contentBlocks); + result.add(mergedMessage); + log.debug( + "Created merged user message with {} content blocks", contentBlocks.size()); + } else { + log.warn("No content blocks created from merged agent conversation"); + } + } + + log.debug("formatMultiAgentConversation: returning {} messages", result.size()); + return result; + } + + @Override + public ChatResponse parseResponse(AnthropicResponse response, Instant startTime) { + return AnthropicResponseParser.parseMessage(response, startTime); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMediaConverter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMediaConverter.java index 378f775cb..a524a5435 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMediaConverter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMediaConverter.java @@ -15,10 +15,8 @@ */ package io.agentscope.core.formatter.anthropic; -import com.anthropic.models.messages.Base64ImageSource; -import com.anthropic.models.messages.ImageBlockParam; -import com.anthropic.models.messages.UrlImageSource; import io.agentscope.core.formatter.MediaUtils; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Source; @@ -30,51 +28,33 @@ public class AnthropicMediaConverter { /** - * Convert ImageBlock to Anthropic ImageBlockParam. For local files, converts to base64. For - * remote URLs, uses URL source directly. + * Convert ImageBlock to Anthropic ImageSource. For local files, converts to + * base64. For remote + * URLs, also converts to base64 (Anthropic API doesn't support URL sources in + * the same way). */ - public ImageBlockParam convertImageBlock(ImageBlock imageBlock) throws Exception { + public AnthropicContent.ImageSource convertImageBlock(ImageBlock imageBlock) throws Exception { Source source = imageBlock.getSource(); if (source instanceof URLSource urlSource) { String url = urlSource.getUrl(); MediaUtils.validateImageExtension(url); - if (MediaUtils.isLocalFile(url)) { - // Convert local file to base64 - String base64Data = MediaUtils.fileToBase64(url); - String mediaType = MediaUtils.determineMediaType(url); + // Convert to base64 (both local and remote) + String base64Data = + MediaUtils.isLocalFile(url) + ? MediaUtils.fileToBase64(url) + : MediaUtils.downloadUrlToBase64(url); + String mediaType = MediaUtils.determineMediaType(url); - return ImageBlockParam.builder() - .source( - Base64ImageSource.builder() - .data(base64Data) - .mediaType( - Base64ImageSource.MediaType.of( - mediaType != null - ? mediaType - : "image/png")) - .build()) - .build(); - } else { - // Use remote URL directly - return ImageBlockParam.builder() - .source(UrlImageSource.builder().url(url).build()) - .build(); - } + return new AnthropicContent.ImageSource( + mediaType != null ? mediaType : "image/png", base64Data); } else if (source instanceof Base64Source base64Source) { String mediaType = base64Source.getMediaType(); String base64Data = base64Source.getData(); - return ImageBlockParam.builder() - .source( - Base64ImageSource.builder() - .data(base64Data) - .mediaType( - Base64ImageSource.MediaType.of( - mediaType != null ? mediaType : "image/png")) - .build()) - .build(); + return new AnthropicContent.ImageSource( + mediaType != null ? mediaType : "image/png", base64Data); } else { throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMessageConverter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMessageConverter.java index 52d7cd324..d9eb90bcb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMessageConverter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMessageConverter.java @@ -15,14 +15,8 @@ */ package io.agentscope.core.formatter.anthropic; -import com.anthropic.core.JsonValue; -import com.anthropic.models.messages.ContentBlockParam; -import com.anthropic.models.messages.ImageBlockParam; -import com.anthropic.models.messages.MessageParam; -import com.anthropic.models.messages.MessageParam.Role; -import com.anthropic.models.messages.TextBlockParam; -import com.anthropic.models.messages.ToolResultBlockParam; -import com.anthropic.models.messages.ToolUseBlockParam; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Msg; @@ -32,6 +26,7 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -39,12 +34,17 @@ import org.slf4j.LoggerFactory; /** - * Converts AgentScope Msg objects to Anthropic SDK MessageParam types. + * Converts AgentScope Msg objects to Anthropic DTO types. * - *

This class handles all message role conversions including system, user, assistant, and tool - * messages. It supports multimodal content (text, images) and tool calling functionality. + *

+ * This class handles all message role conversions including system, user, + * assistant, and tool + * messages. It supports multimodal content (text, images) and tool calling + * functionality. * - *

Important: In Anthropic API, only the first message can be a system message. Non-first system + *

+ * Important: In Anthropic API, only the first message can be a system message. + * Non-first system * messages are converted to user messages. */ public class AnthropicMessageConverter { @@ -60,19 +60,48 @@ public class AnthropicMessageConverter { * @param toolResultConverter Function to convert tool result blocks to strings */ public AnthropicMessageConverter(Function, String> toolResultConverter) { - this.mediaConverter = new AnthropicMediaConverter(); + this(toolResultConverter, new AnthropicMediaConverter()); + } + + /** + * Create an AnthropicMessageConverter with custom media converter and default + * tool result converter. + * + * @param mediaConverter Custom AnthropicMediaConverter + */ + public AnthropicMessageConverter(AnthropicMediaConverter mediaConverter) { + this( + blocks -> { + StringBuilder sb = new StringBuilder(); + if (blocks != null) { + for (ContentBlock block : blocks) { + if (block instanceof TextBlock tb) { + sb.append(tb.getText()); + } + } + } + return sb.toString(); + }, + mediaConverter); + } + + public AnthropicMessageConverter( + Function, String> toolResultConverter, + AnthropicMediaConverter mediaConverter) { this.toolResultConverter = toolResultConverter; + this.mediaConverter = mediaConverter; } /** - * Convert list of Msg to list of Anthropic MessageParam. Handles the special case where tool + * Convert list of Msg to list of Anthropic messages. Handles the special case + * where tool * results need to be in separate user messages. * * @param messages The messages to convert - * @return List of MessageParam for Anthropic API + * @return List of AnthropicMessage for Anthropic API */ - public List convert(List messages) { - List result = new ArrayList<>(); + public List convert(List messages) { + List result = new ArrayList<>(); for (int i = 0; i < messages.size(); i++) { Msg msg = messages.get(i); @@ -94,7 +123,7 @@ public List convert(List messages) { // Add regular content if present if (!nonToolBlocks.isEmpty()) { - MessageParam regularMsg = convertMessageContent(msg, nonToolBlocks, i == 0); + AnthropicMessage regularMsg = convertMessageContent(msg, nonToolBlocks, i == 0); if (regularMsg != null) { result.add(regularMsg); } @@ -105,9 +134,10 @@ public List convert(List messages) { result.add(convertToolResult(toolResult)); } } else { - MessageParam param = convertMessageContent(msg, msg.getContent(), isFirstMessage); - if (param != null) { - result.add(param); + AnthropicMessage anthropicMsg = + convertMessageContent(msg, msg.getContent(), isFirstMessage); + if (anthropicMsg != null) { + result.add(anthropicMsg); } } } @@ -116,52 +146,35 @@ public List convert(List messages) { } /** - * Convert message content to MessageParam. + * Convert message content to AnthropicMessage. */ - private MessageParam convertMessageContent( + private AnthropicMessage convertMessageContent( Msg msg, List blocks, boolean isFirstMessage) { - Role role = convertRole(msg.getRole(), isFirstMessage); - List contentBlocks = new ArrayList<>(); + String role = convertRole(msg.getRole(), isFirstMessage); + List contentBlocks = new ArrayList<>(); for (ContentBlock block : blocks) { if (block instanceof TextBlock tb) { - contentBlocks.add( - ContentBlockParam.ofText( - TextBlockParam.builder().text(tb.getText()).build())); + contentBlocks.add(AnthropicContent.text(tb.getText())); } else if (block instanceof ThinkingBlock thinkingBlock) { // Anthropic supports thinking blocks natively - contentBlocks.add( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text(thinkingBlock.getThinking()) - .build())); + contentBlocks.add(AnthropicContent.thinking(thinkingBlock.getThinking())); } else if (block instanceof ImageBlock ib) { try { - ImageBlockParam imageParam = mediaConverter.convertImageBlock(ib); - contentBlocks.add(ContentBlockParam.ofImage(imageParam)); + AnthropicContent.ImageSource imageSource = mediaConverter.convertImageBlock(ib); + contentBlocks.add( + AnthropicContent.image( + imageSource.getMediaType(), imageSource.getData())); } catch (Exception e) { log.warn("Failed to process ImageBlock: {}", e.getMessage()); contentBlocks.add( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text( - "[Image - processing failed: " - + e.getMessage() - + "]") - .build())); + AnthropicContent.text( + "[Image - processing failed: " + e.getMessage() + "]")); } } else if (block instanceof ToolUseBlock tub) { - contentBlocks.add( - ContentBlockParam.ofToolUse( - ToolUseBlockParam.builder() - .id(tub.getId()) - .name(tub.getName()) - .input( - JsonValue.from( - tub.getInput() != null - ? tub.getInput() - : Map.of())) - .build())); + Map input = + tub.getInput() != null ? tub.getInput() : new HashMap<>(); + contentBlocks.add(AnthropicContent.toolUse(tub.getId(), tub.getName(), input)); } // ToolResultBlock is handled separately in convert() method } @@ -170,43 +183,42 @@ private MessageParam convertMessageContent( return null; } - return MessageParam.builder() - .role(role) - .content(MessageParam.Content.ofBlockParams(contentBlocks)) - .build(); + return new AnthropicMessage(role, contentBlocks); } /** * Convert tool result to separate user message. */ - private MessageParam convertToolResult(ToolResultBlock toolResult) { - // Convert output to content blocks - List blocks = new ArrayList<>(); - + private AnthropicMessage convertToolResult(ToolResultBlock toolResult) { + // Convert output to content string or blocks Object output = toolResult.getOutput(); + Object contentValue; + if (output == null) { - blocks.add( - ToolResultBlockParam.Content.Block.ofText( - TextBlockParam.builder().text((String) null).build())); + contentValue = ""; } else if (output instanceof List) { - // Multi-block output + // Multi-block output - convert to list of content blocks List outputList = (List) output; + List blocks = new ArrayList<>(); + for (Object item : outputList) { if (item instanceof ContentBlock cb) { if (cb instanceof TextBlock tb) { - blocks.add( - ToolResultBlockParam.Content.Block.ofText( - TextBlockParam.builder().text(tb.getText()).build())); + blocks.add(AnthropicContent.text(tb.getText())); } else if (cb instanceof ImageBlock ib) { try { - ImageBlockParam imageParam = mediaConverter.convertImageBlock(ib); - blocks.add(ToolResultBlockParam.Content.Block.ofImage(imageParam)); + AnthropicContent.ImageSource imageSource = + mediaConverter.convertImageBlock(ib); + blocks.add( + AnthropicContent.image( + imageSource.getMediaType(), imageSource.getData())); } catch (Exception e) { log.warn("Failed to process ImageBlock in tool result: {}", e); } } } } + contentValue = blocks.isEmpty() ? "" : blocks; } else { // String output String outputStr = @@ -215,38 +227,30 @@ private MessageParam convertToolResult(ToolResultBlock toolResult) { : (output instanceof ContentBlock ? toolResultConverter.apply(List.of((ContentBlock) output)) : output.toString()); - blocks.add( - ToolResultBlockParam.Content.Block.ofText( - TextBlockParam.builder().text(outputStr).build())); + contentValue = outputStr; } - // Create tool result block - ToolResultBlockParam toolResultParam = - ToolResultBlockParam.builder() - .toolUseId(toolResult.getId()) - .content(ToolResultBlockParam.Content.ofBlocks(blocks)) - .build(); + // Create tool result content + AnthropicContent toolResultContent = + AnthropicContent.toolResult(toolResult.getId(), contentValue, null); // Wrap in user message - return MessageParam.builder() - .role(Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of(ContentBlockParam.ofToolResult(toolResultParam)))) - .build(); + AnthropicMessage message = new AnthropicMessage("user"); + message.addContent(toolResultContent); + return message; } /** - * Convert AgentScope MsgRole to Anthropic Role. Important: Anthropic only allows the first - * message to be system. Non-first system messages are converted to user. + * Convert AgentScope MsgRole to Anthropic role string. Important: Anthropic + * only allows the + * first message to be system. Non-first system messages are converted to user. */ - private Role convertRole(MsgRole msgRole, boolean isFirstMessage) { + private String convertRole(MsgRole msgRole, boolean isFirstMessage) { return switch (msgRole) { - case SYSTEM -> isFirstMessage ? Role.USER : Role.USER; // Anthropic uses user for - // system messages - case USER -> Role.USER; - case ASSISTANT -> Role.ASSISTANT; - case TOOL -> Role.USER; // Tool results are always user messages + case SYSTEM -> "user"; // Anthropic uses user for system messages + case USER -> "user"; + case ASSISTANT -> "assistant"; + case TOOL -> "user"; // Tool results are always user messages }; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMultiAgentFormatter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMultiAgentFormatter.java index 028b6c31b..560f6d21e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMultiAgentFormatter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicMultiAgentFormatter.java @@ -15,11 +15,9 @@ */ package io.agentscope.core.formatter.anthropic; -import com.anthropic.models.messages.ContentBlockParam; -import com.anthropic.models.messages.ImageBlockParam; -import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.MessageParam; -import com.anthropic.models.messages.TextBlockParam; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; +import io.agentscope.core.formatter.anthropic.dto.AnthropicResponse; import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; @@ -33,15 +31,17 @@ import org.slf4j.LoggerFactory; /** - * Multi-agent formatter for Anthropic Messages API. Converts AgentScope Msg objects to Anthropic - * SDK MessageParam objects with multi-agent support. + * Multi-agent formatter for Anthropic Messages API. Converts AgentScope Msg + * objects to Anthropic + * DTO objects with multi-agent support. * - *

This formatter handles conversations between multiple agents by: + *

+ * This formatter handles conversations between multiple agents by: * *

    - *
  • Grouping multi-agent messages into conversation history - *
  • Using special markup (history tags) to structure conversations - *
  • Consolidating multi-agent conversations into single user messages + *
  • Grouping multi-agent messages into conversation history + *
  • Using special markup (history tags) to structure conversations + *
  • Consolidating multi-agent conversations into single user messages *
*/ public class AnthropicMultiAgentFormatter extends AnthropicBaseFormatter { @@ -55,27 +55,55 @@ public class AnthropicMultiAgentFormatter extends AnthropicBaseFormatter { private final AnthropicMediaConverter mediaConverter; private final String conversationHistoryPrompt; - private boolean isFirstAgentMessageGroup = true; - /** Create an AnthropicMultiAgentFormatter with default conversation history prompt. */ + /** + * Create an AnthropicMultiAgentFormatter with default conversation history + * prompt. + */ public AnthropicMultiAgentFormatter() { - this(DEFAULT_CONVERSATION_HISTORY_PROMPT); + this(DEFAULT_CONVERSATION_HISTORY_PROMPT, new AnthropicMediaConverter()); } /** - * Create an AnthropicMultiAgentFormatter with custom conversation history prompt. + * Create an AnthropicMultiAgentFormatter with custom conversation history + * prompt. * - * @param conversationHistoryPrompt The prompt to prepend before conversation history + * @param conversationHistoryPrompt The prompt to prepend before conversation + * history */ public AnthropicMultiAgentFormatter(String conversationHistoryPrompt) { - this.mediaConverter = new AnthropicMediaConverter(); + this(conversationHistoryPrompt, new AnthropicMediaConverter()); + } + + /** + * Create an AnthropicMultiAgentFormatter with custom media converter for + * testing. + * + * @param mediaConverter Custom AnthropicMediaConverter (e.g. mock) + */ + public AnthropicMultiAgentFormatter(AnthropicMediaConverter mediaConverter) { + this(DEFAULT_CONVERSATION_HISTORY_PROMPT, mediaConverter); + } + + /** + * Create an AnthropicMultiAgentFormatter with custom conversation history + * prompt and media converter. + * + * @param conversationHistoryPrompt The prompt to prepend before conversation + * history + * @param mediaConverter Custom AnthropicMediaConverter + */ + public AnthropicMultiAgentFormatter( + String conversationHistoryPrompt, AnthropicMediaConverter mediaConverter) { + super(new AnthropicMessageConverter(mediaConverter)); + this.mediaConverter = mediaConverter; this.conversationHistoryPrompt = conversationHistoryPrompt; } @Override - public List doFormat(List msgs) { - List result = new ArrayList<>(); - this.isFirstAgentMessageGroup = true; + public List doFormat(List msgs) { + List result = new ArrayList<>(); + boolean isFirstAgentMessageGroup = true; // Group messages List groups = groupMessages(msgs); @@ -87,7 +115,11 @@ public List doFormat(List msgs) { result.addAll(messageConverter.convert(List.of(systemMsg))); } case TOOL_SEQUENCE -> result.addAll(formatToolSequence(group.messages)); - case AGENT_CONVERSATION -> result.addAll(formatAgentConversation(group.messages)); + case AGENT_CONVERSATION -> { + result.addAll( + formatAgentConversation(group.messages, isFirstAgentMessageGroup)); + isFirstAgentMessageGroup = false; + } } } @@ -95,12 +127,8 @@ public List doFormat(List msgs) { } @Override - public ChatResponse parseResponse(Object response, Instant startTime) { - if (response instanceof Message message) { - return AnthropicResponseParser.parseMessage(message, startTime); - } else { - throw new IllegalArgumentException("Unsupported response type: " + response.getClass()); - } + public ChatResponse parseResponse(AnthropicResponse response, Instant startTime) { + return AnthropicResponseParser.parseMessage(response, startTime); } // ========== Private Helper Methods ========== @@ -174,32 +202,31 @@ private GroupType determineMessageType(Msg msg) { } /** Format tool sequence (tool calls and results). */ - private List formatToolSequence(List messages) { + private List formatToolSequence(List messages) { return messageConverter.convert(messages); } /** Format agent conversation messages with history tags. */ - private List formatAgentConversation(List messages) { - boolean isFirst = isFirstAgentMessageGroup; - isFirstAgentMessageGroup = false; - - String prompt = isFirst ? conversationHistoryPrompt : ""; + private List formatAgentConversation( + List messages, boolean isFirstAgentMessageGroup) { + String prompt = isFirstAgentMessageGroup ? conversationHistoryPrompt : ""; // Merge conversation with history tags List conversationBlocks = AnthropicConversationMerger.mergeConversation(messages, prompt); - // Convert to ContentBlockParam list - List contentBlocks = new ArrayList<>(); + // Convert to AnthropicContent list + List contentBlocks = new ArrayList<>(); for (Object block : conversationBlocks) { if (block instanceof String text) { - contentBlocks.add( - ContentBlockParam.ofText(TextBlockParam.builder().text(text).build())); + contentBlocks.add(AnthropicContent.text(text)); } else if (block instanceof ImageBlock ib) { try { - ImageBlockParam imageParam = mediaConverter.convertImageBlock(ib); - contentBlocks.add(ContentBlockParam.ofImage(imageParam)); + AnthropicContent.ImageSource imageSource = mediaConverter.convertImageBlock(ib); + contentBlocks.add( + AnthropicContent.image( + imageSource.getMediaType(), imageSource.getData())); } catch (Exception e) { log.warn("Failed to process ImageBlock in multi-agent conversation: {}", e); } @@ -210,10 +237,6 @@ private List formatAgentConversation(List messages) { return List.of(); } - return List.of( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content(MessageParam.Content.ofBlockParams(contentBlocks)) - .build()); + return List.of(new AnthropicMessage("user", contentBlocks)); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParser.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParser.java index 3f348836f..fb81e36c6 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParser.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParser.java @@ -15,10 +15,9 @@ */ package io.agentscope.core.formatter.anthropic; -import com.anthropic.core.JsonValue; -import com.anthropic.core.ObjectMappers; -import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.RawMessageStreamEvent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicResponse; +import io.agentscope.core.formatter.anthropic.dto.AnthropicStreamEvent; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ThinkingBlock; @@ -36,7 +35,8 @@ import reactor.core.publisher.Flux; /** - * Parses Anthropic API responses (both streaming and non-streaming) into AgentScope ChatResponse + * Parses Anthropic API responses (both streaming and non-streaming) into + * AgentScope ChatResponse * objects. */ public class AnthropicResponseParser { @@ -44,64 +44,75 @@ public class AnthropicResponseParser { private static final Logger log = LoggerFactory.getLogger(AnthropicResponseParser.class); /** - * Parse non-streaming Anthropic Message to ChatResponse. + * Parse non-streaming Anthropic response to ChatResponse. */ - public static ChatResponse parseMessage(Message message, Instant startTime) { + public static ChatResponse parseMessage(AnthropicResponse message, Instant startTime) { List contentBlocks = new ArrayList<>(); // Process content blocks - for (com.anthropic.models.messages.ContentBlock block : message.content()) { - // Text block - block.text() - .ifPresent( - textBlock -> - contentBlocks.add( - TextBlock.builder().text(textBlock.text()).build())); - - // Tool use block - block.toolUse() - .ifPresent( - toolUse -> { - Map input = - parseJsonInput(toolUse._input(), toolUse.name()); - contentBlocks.add( - ToolUseBlock.builder() - .id(toolUse.id()) - .name(toolUse.name()) - .input(input) - .content( - toolUse._input() != null - ? toolUse._input().toString() - : "") - .build()); - }); - - // Thinking block (extended thinking) - block.thinking() - .ifPresent( - thinking -> - contentBlocks.add( - ThinkingBlock.builder() - .thinking(thinking.thinking()) - .build())); + if (message.getContent() != null) { + for (AnthropicContent block : message.getContent()) { + String type = block.getType(); + if (type == null) continue; + + switch (type) { + case "text" -> { + if (block.getText() != null) { + contentBlocks.add(TextBlock.builder().text(block.getText()).build()); + } + } + case "tool_use" -> { + Map input = parseInput(block.getInput(), block.getName()); + contentBlocks.add( + ToolUseBlock.builder() + .id(block.getId()) + .name(block.getName()) + .input(input) + .content( + block.getInput() != null + ? block.getInput().toString() + : "") + .build()); + } + case "thinking" -> { + if (block.getThinking() != null) { + contentBlocks.add( + ThinkingBlock.builder().thinking(block.getThinking()).build()); + } + } + } + } } // Parse usage - ChatUsage usage = - ChatUsage.builder() - .inputTokens((int) message.usage().inputTokens()) - .outputTokens((int) message.usage().outputTokens()) - .time(Duration.between(startTime, Instant.now()).toMillis() / 1000.0) - .build(); + ChatUsage usage = null; + if (message.getUsage() != null) { + usage = + ChatUsage.builder() + .inputTokens( + message.getUsage().getInputTokens() != null + ? message.getUsage().getInputTokens() + : 0) + .outputTokens( + message.getUsage().getOutputTokens() != null + ? message.getUsage().getOutputTokens() + : 0) + .time(Duration.between(startTime, Instant.now()).toMillis() / 1000.0) + .build(); + } - return ChatResponse.builder().id(message.id()).content(contentBlocks).usage(usage).build(); + return ChatResponse.builder() + .id(message.getId()) + .content(contentBlocks) + .usage(usage) + .build(); } /** * Parse streaming Anthropic events to ChatResponse Flux. */ public static Flux parseStreamEvents( - Flux eventFlux, Instant startTime) { + Flux eventFlux, Instant startTime) { return eventFlux .flatMap( event -> { @@ -118,92 +129,91 @@ public static Flux parseStreamEvents( /** * Parse single stream event. */ - private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instant startTime) { + private static ChatResponse parseStreamEvent(AnthropicStreamEvent event, Instant startTime) { List contentBlocks = new ArrayList<>(); ChatUsage usage = null; String messageId = null; - // Message start - if (event.isMessageStart()) { - messageId = event.asMessageStart().message().id(); + String eventType = event.getType(); + if (eventType == null) { + return ChatResponse.builder().content(contentBlocks).build(); } - // Content block delta - text - if (event.isContentBlockDelta()) { - var deltaEvent = event.asContentBlockDelta(); - - deltaEvent - .delta() - .text() - .ifPresent( - textDelta -> - contentBlocks.add( - TextBlock.builder().text(textDelta.text()).build())); - - // Input JSON delta (tool calling) - deltaEvent - .delta() - .inputJson() - .ifPresent( - jsonDelta -> { - // Create fragment ToolUseBlock for accumulation - contentBlocks.add( - ToolUseBlock.builder() - .id("") // Empty ID indicates fragment - .name("__fragment__") // Fragment marker - .content(jsonDelta.partialJson()) - .input(Map.of()) - .build()); - }); - } - - // Content block start - tool use - if (event.isContentBlockStart()) { - var startEvent = event.asContentBlockStart(); - - startEvent - .contentBlock() - .toolUse() - .ifPresent( - toolUse -> { - contentBlocks.add( - ToolUseBlock.builder() - .id(toolUse.id()) - .name(toolUse.name()) - .input(Map.of()) - .content("") - .build()); - }); - } - - // Message delta - usage information - if (event.isMessageDelta()) { - var messageDelta = event.asMessageDelta(); - usage = - ChatUsage.builder() - .outputTokens((int) messageDelta.usage().outputTokens()) - .time(Duration.between(startTime, Instant.now()).toMillis() / 1000.0) - .build(); + switch (eventType) { + case "message_start" -> { + if (event.getMessage() != null) { + messageId = event.getMessage().getId(); + } + } + case "content_block_start" -> { + AnthropicContent block = event.getContentBlock(); + if (block != null && "tool_use".equals(block.getType())) { + contentBlocks.add( + ToolUseBlock.builder() + .id(block.getId()) + .name(block.getName()) + .input(Map.of()) + .content("") + .build()); + } + } + case "content_block_delta" -> { + AnthropicStreamEvent.Delta delta = event.getDelta(); + if (delta != null) { + // Text delta + if (delta.getText() != null) { + contentBlocks.add(TextBlock.builder().text(delta.getText()).build()); + } + // Tool input JSON delta + if (delta.getPartialJson() != null) { + contentBlocks.add( + ToolUseBlock.builder() + .id("") // Empty ID indicates fragment + .name("__fragment__") // Fragment marker + .content(delta.getPartialJson()) + .input(Map.of()) + .build()); + } + } + } + case "message_delta" -> { + if (event.getUsage() != null) { + usage = + ChatUsage.builder() + .outputTokens( + event.getUsage().getOutputTokens() != null + ? event.getUsage().getOutputTokens() + : 0) + .time( + Duration.between(startTime, Instant.now()).toMillis() + / 1000.0) + .build(); + } + } } return ChatResponse.builder().id(messageId).content(contentBlocks).usage(usage).build(); } /** - * Parse JsonValue to Map for tool input. + * Parse input object to Map for tool input. */ - private static Map parseJsonInput(JsonValue jsonValue, String toolName) { - if (jsonValue == null) { + @SuppressWarnings("unchecked") + private static Map parseInput(Object input, String toolName) { + if (input == null) { return Map.of(); } try { - String jsonString = ObjectMappers.jsonMapper().writeValueAsString(jsonValue); - @SuppressWarnings("unchecked") + if (input instanceof Map) { + return (Map) input; + } + // Convert to JSON string and back to Map + String jsonString = JsonUtils.getJsonCodec().toJson(input); Map result = JsonUtils.getJsonCodec().fromJson(jsonString, Map.class); return result != null ? result : Map.of(); } catch (Exception e) { - log.warn("Failed to parse tool input JSON for tool {}: {}", toolName, e.getMessage()); + log.warn("Failed to parse tool input for tool {}: {}", toolName, e.getMessage()); return Map.of(); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicToolsHelper.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicToolsHelper.java index d69d922fc..ec0797415 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicToolsHelper.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/AnthropicToolsHelper.java @@ -15,16 +15,14 @@ */ package io.agentscope.core.formatter.anthropic; -import com.anthropic.core.JsonValue; -import com.anthropic.core.ObjectMappers; -import com.anthropic.models.messages.MessageCreateParams; -import com.anthropic.models.messages.Tool; -import com.anthropic.models.messages.ToolChoiceAny; -import com.anthropic.models.messages.ToolChoiceAuto; -import com.anthropic.models.messages.ToolChoiceTool; +import io.agentscope.core.formatter.anthropic.dto.AnthropicRequest; +import io.agentscope.core.formatter.anthropic.dto.AnthropicTool; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.ToolChoice; import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.util.JsonUtils; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.slf4j.Logger; @@ -38,165 +36,150 @@ public class AnthropicToolsHelper { private static final Logger log = LoggerFactory.getLogger(AnthropicToolsHelper.class); /** - * Apply tools to the message create params builder. + * Apply tools to the Anthropic request. * - * @param builder The message create params builder - * @param tools List of tool schemas + * @param request The Anthropic request + * @param tools List of tool schemas * @param options Generate options containing tool choice */ public static void applyTools( - MessageCreateParams.Builder builder, List tools, GenerateOptions options) { + AnthropicRequest request, List tools, GenerateOptions options) { + applyTools(request, tools); + + if (options != null && options.getToolChoice() != null) { + applyToolChoice(request, options.getToolChoice()); + } + } + + /** + * Apply tools to the Anthropic request. + * + * @param request The Anthropic request + * @param tools List of tool schemas + */ + public static void applyTools(AnthropicRequest request, List tools) { if (tools == null || tools.isEmpty()) { return; } // Convert and add tools + List anthropicTools = new ArrayList<>(); for (ToolSchema schema : tools) { - Tool tool = - Tool.builder() - .name(schema.getName()) - .description(schema.getDescription()) - .inputSchema(convertToJsonValue(schema.getParameters())) - .build(); - - builder.addTool(tool); - } + Map inputSchema = schema.getParameters(); - // Apply tool choice if specified - if (options != null && options.getToolChoice() != null) { - applyToolChoice(builder, options.getToolChoice()); + AnthropicTool tool = + new AnthropicTool(schema.getName(), schema.getDescription(), inputSchema); + anthropicTools.add(tool); } + + request.setTools(anthropicTools); } /** - * Convert tool parameters map to Anthropic JsonValue. + * Convert tool parameters to Map. */ - private static JsonValue convertToJsonValue(Object parameters) { + @SuppressWarnings("unchecked") + private static Map convertToMap(Object parameters) { try { - return JsonValue.from(ObjectMappers.jsonMapper().valueToTree(parameters)); + if (parameters == null) { + return new HashMap<>(); + } + // Convert to JSON string and back to Map + String json = JsonUtils.getJsonCodec().toJson(parameters); + return JsonUtils.getJsonCodec().fromJson(json, Map.class); } catch (Exception e) { - log.error("Failed to convert tool parameters to JsonValue", e); - return JsonValue.from(null); + log.error("Failed to convert tool parameters to Map", e); + return new HashMap<>(); } } /** - * Apply tool choice to the builder. + * Apply tool choice to the request. */ - private static void applyToolChoice( - MessageCreateParams.Builder builder, ToolChoice toolChoice) { + public static void applyToolChoice(AnthropicRequest request, ToolChoice toolChoice) { if (toolChoice instanceof ToolChoice.Auto) { - builder.toolChoice( - com.anthropic.models.messages.ToolChoice.ofAuto( - ToolChoiceAuto.builder().build())); + Map choice = new HashMap<>(); + choice.put("type", "auto"); + request.setToolChoice(choice); } else if (toolChoice instanceof ToolChoice.None) { // Anthropic doesn't have None, use Any instead - builder.toolChoice( - com.anthropic.models.messages.ToolChoice.ofAny( - ToolChoiceAny.builder().build())); + Map choice = new HashMap<>(); + choice.put("type", "any"); + request.setToolChoice(choice); } else if (toolChoice instanceof ToolChoice.Required) { - // Anthropic doesn't have a direct "required" option, use "any" which forces tool + // Anthropic doesn't have a direct "required" option, use "any" which forces + // tool // use log.warn( "Anthropic API doesn't support ToolChoice.Required directly, using 'any'" + " instead"); - builder.toolChoice( - com.anthropic.models.messages.ToolChoice.ofAny( - ToolChoiceAny.builder().build())); + Map choice = new HashMap<>(); + choice.put("type", "any"); + request.setToolChoice(choice); } else if (toolChoice instanceof ToolChoice.Specific specific) { - builder.toolChoice( - com.anthropic.models.messages.ToolChoice.ofTool( - ToolChoiceTool.builder().name(specific.toolName()).build())); + Map choice = new HashMap<>(); + choice.put("type", "tool"); + choice.put("name", specific.toolName()); + request.setToolChoice(choice); } else { log.warn("Unknown tool choice type: {}", toolChoice); } } /** - * Apply generation options to the builder. + * Apply generation options to the request. * - * @param builder The message create params builder - * @param options Generate options + * @param request The Anthropic request + * @param options Generate options * @param defaultOptions Default generate options */ public static void applyOptions( - MessageCreateParams.Builder builder, - GenerateOptions options, - GenerateOptions defaultOptions) { + AnthropicRequest request, GenerateOptions options, GenerateOptions defaultOptions) { // Temperature Double temperature = getOption(options, defaultOptions, GenerateOptions::getTemperature); if (temperature != null) { - builder.temperature(temperature); + request.setTemperature(temperature); } // Top P Double topP = getOption(options, defaultOptions, GenerateOptions::getTopP); if (topP != null) { - builder.topP(topP); + request.setTopP(topP); } // Top K Integer topK = getOption(options, defaultOptions, GenerateOptions::getTopK); if (topK != null) { - builder.topK(topK.longValue()); + request.setTopK(topK); } // Max tokens Integer maxTokens = getOption(options, defaultOptions, GenerateOptions::getMaxTokens); if (maxTokens != null) { - builder.maxTokens(maxTokens); + request.setMaxTokens(maxTokens); } - // Apply additional parameters (merge defaultOptions first, then options to override) - // Apply additional headers - applyAdditionalHeaders(builder, defaultOptions); - applyAdditionalHeaders(builder, options); - - // Apply additional body params - applyAdditionalBodyParams(builder, defaultOptions); - applyAdditionalBodyParams(builder, options); - - // Apply additional query params - applyAdditionalQueryParams(builder, defaultOptions); - applyAdditionalQueryParams(builder, options); + // Note: Additional headers and query params are handled by the client, not the + // request + // Additional body params can be added to metadata if needed + applyAdditionalBodyParams(request, defaultOptions); + applyAdditionalBodyParams(request, options); } - private static void applyAdditionalHeaders( - MessageCreateParams.Builder builder, GenerateOptions opts) { - if (opts == null) return; - Map headers = opts.getAdditionalHeaders(); - if (headers != null && !headers.isEmpty()) { - for (Map.Entry entry : headers.entrySet()) { - builder.putAdditionalHeader(entry.getKey(), entry.getValue()); - } - log.debug("Applied {} additional headers to Anthropic request", headers.size()); - } - } - - private static void applyAdditionalBodyParams( - MessageCreateParams.Builder builder, GenerateOptions opts) { + private static void applyAdditionalBodyParams(AnthropicRequest request, GenerateOptions opts) { if (opts == null) return; Map params = opts.getAdditionalBodyParams(); if (params != null && !params.isEmpty()) { - for (Map.Entry entry : params.entrySet()) { - builder.putAdditionalBodyProperty(entry.getKey(), JsonValue.from(entry.getValue())); - } + Map metadata = + request.getMetadata() != null + ? new HashMap<>(request.getMetadata()) + : new HashMap<>(); + metadata.putAll(params); + request.setMetadata(metadata); log.debug("Applied {} additional body params to Anthropic request", params.size()); } } - private static void applyAdditionalQueryParams( - MessageCreateParams.Builder builder, GenerateOptions opts) { - if (opts == null) return; - Map params = opts.getAdditionalQueryParams(); - if (params != null && !params.isEmpty()) { - for (Map.Entry entry : params.entrySet()) { - builder.putAdditionalQueryParam(entry.getKey(), entry.getValue()); - } - log.debug("Applied {} additional query params to Anthropic request", params.size()); - } - } - /** * Get option value, preferring specific over default. */ diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicContent.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicContent.java new file mode 100644 index 000000000..e44cca99e --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicContent.java @@ -0,0 +1,231 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * Represents a content block in an Anthropic message. + * Can be text, image, tool_use, or tool_result. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicContent { + + @JsonProperty("type") + private String type; + + // For text content + @JsonProperty("text") + private String text; + + // For image content + @JsonProperty("source") + private ImageSource source; + + // For tool_use content + @JsonProperty("id") + private String id; + + @JsonProperty("name") + private String name; + + @JsonProperty("input") + private Map input; + + // For tool_result content + @JsonProperty("tool_use_id") + private String toolUseId; + + @JsonProperty("content") + private Object content; // Can be string or array of content blocks + + @JsonProperty("is_error") + private Boolean isError; + + // For thinking content (extended thinking) + @JsonProperty("thinking") + private String thinking; + + public AnthropicContent() {} + + public static AnthropicContent text(String text) { + AnthropicContent content = new AnthropicContent(); + content.type = "text"; + content.text = text; + return content; + } + + public static AnthropicContent image(String mediaType, String data) { + AnthropicContent content = new AnthropicContent(); + content.type = "image"; + content.source = new ImageSource(mediaType, data); + return content; + } + + public static AnthropicContent toolUse(String id, String name, Map input) { + AnthropicContent content = new AnthropicContent(); + content.type = "tool_use"; + content.id = id; + content.name = name; + content.input = input; + return content; + } + + public static AnthropicContent toolResult(String toolUseId, Object content, Boolean isError) { + AnthropicContent result = new AnthropicContent(); + result.type = "tool_result"; + result.toolUseId = toolUseId; + result.content = content; + result.isError = isError; + return result; + } + + public static AnthropicContent thinking(String thinking) { + AnthropicContent content = new AnthropicContent(); + content.type = "thinking"; + content.thinking = thinking; + return content; + } + + // Getters and setters + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public ImageSource getSource() { + return source; + } + + public void setSource(ImageSource source) { + this.source = source; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Map getInput() { + return input; + } + + public void setInput(Map input) { + this.input = input; + } + + public String getToolUseId() { + return toolUseId; + } + + public void setToolUseId(String toolUseId) { + this.toolUseId = toolUseId; + } + + public Object getContent() { + return content; + } + + public void setContent(Object content) { + this.content = content; + } + + public Boolean getIsError() { + return isError; + } + + public void setIsError(Boolean isError) { + this.isError = isError; + } + + public String getThinking() { + return thinking; + } + + public void setThinking(String thinking) { + this.thinking = thinking; + } + + /** + * Image source for image content blocks. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ImageSource { + @JsonProperty("type") + private String type = "base64"; + + @JsonProperty("media_type") + private String mediaType; + + @JsonProperty("data") + private String data; + + public ImageSource() {} + + public ImageSource(String mediaType, String data) { + this.mediaType = mediaType; + this.data = data; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicMessage.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicMessage.java new file mode 100644 index 000000000..3fa86aecf --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicMessage.java @@ -0,0 +1,72 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a message in an Anthropic API request. + * Corresponds to MessageParam in the SDK. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicMessage { + + @JsonProperty("role") + private String role; + + @JsonProperty("content") + private List content; + + public AnthropicMessage() { + this.content = new ArrayList<>(); + } + + public AnthropicMessage(String role) { + this.role = role; + this.content = new ArrayList<>(); + } + + public AnthropicMessage(String role, List content) { + this.role = role; + this.content = content != null ? content : new ArrayList<>(); + } + + public void addContent(AnthropicContent contentBlock) { + if (this.content == null) { + this.content = new ArrayList<>(); + } + this.content.add(contentBlock); + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public List getContent() { + return content; + } + + public void setContent(List content) { + this.content = content; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicRequest.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicRequest.java new file mode 100644 index 000000000..55d375299 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicRequest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents an Anthropic API request. + * Corresponds to MessageCreateParams in the SDK. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicRequest { + + @JsonProperty("model") + private String model; + + @JsonProperty("messages") + private List messages; + + @JsonProperty("max_tokens") + private Integer maxTokens; + + @JsonProperty("temperature") + private Double temperature; + + @JsonProperty("top_p") + private Double topP; + + @JsonProperty("top_k") + private Integer topK; + + @JsonProperty("system") + private Object system; // Can be string or array of content blocks + + @JsonProperty("tools") + private List tools; + + @JsonProperty("tool_choice") + private Object toolChoice; // Can be string ("auto", "any") or object with type and name + + @JsonProperty("stream") + private Boolean stream; + + @JsonProperty("stop_sequences") + private List stopSequences; + + @JsonProperty("metadata") + private Map metadata; + + public AnthropicRequest() {} + + // Getters and setters + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public List getMessages() { + return messages; + } + + public void setMessages(List messages) { + this.messages = messages; + } + + public Integer getMaxTokens() { + return maxTokens; + } + + public void setMaxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + } + + public Double getTemperature() { + return temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + public Double getTopP() { + return topP; + } + + public void setTopP(Double topP) { + this.topP = topP; + } + + public Integer getTopK() { + return topK; + } + + public void setTopK(Integer topK) { + this.topK = topK; + } + + public Object getSystem() { + return system; + } + + public void setSystem(Object system) { + this.system = system; + } + + public List getTools() { + return tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public Object getToolChoice() { + return toolChoice; + } + + public void setToolChoice(Object toolChoice) { + this.toolChoice = toolChoice; + } + + public Boolean getStream() { + return stream; + } + + public void setStream(Boolean stream) { + this.stream = stream; + } + + public List getStopSequences() { + return stopSequences; + } + + public void setStopSequences(List stopSequences) { + this.stopSequences = stopSequences; + } + + public Map getMetadata() { + return metadata != null ? Collections.unmodifiableMap(new HashMap<>(metadata)) : null; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata != null ? new HashMap<>(metadata) : null; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicResponse.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicResponse.java new file mode 100644 index 000000000..faa84b4bd --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicResponse.java @@ -0,0 +1,117 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Represents an Anthropic API response. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicResponse { + + @JsonProperty("id") + private String id; + + @JsonProperty("type") + private String type; + + @JsonProperty("role") + private String role; + + @JsonProperty("content") + private List content; + + @JsonProperty("model") + private String model; + + @JsonProperty("stop_reason") + private String stopReason; + + @JsonProperty("stop_sequence") + private String stopSequence; + + @JsonProperty("usage") + private AnthropicUsage usage; + + public AnthropicResponse() {} + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public List getContent() { + return content; + } + + public void setContent(List content) { + this.content = content; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getStopReason() { + return stopReason; + } + + public void setStopReason(String stopReason) { + this.stopReason = stopReason; + } + + public String getStopSequence() { + return stopSequence; + } + + public void setStopSequence(String stopSequence) { + this.stopSequence = stopSequence; + } + + public AnthropicUsage getUsage() { + return usage; + } + + public void setUsage(AnthropicUsage usage) { + this.usage = usage; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicStreamEvent.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicStreamEvent.java new file mode 100644 index 000000000..d6edbb259 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicStreamEvent.java @@ -0,0 +1,163 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a Server-Sent Event from Anthropic's streaming API. + * Event types include: message_start, content_block_start, content_block_delta, + * content_block_stop, message_delta, message_stop, ping, error. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicStreamEvent { + + @JsonProperty("type") + private String type; + + // For message_start + @JsonProperty("message") + private AnthropicResponse message; + + // For content_block_start + @JsonProperty("index") + private Integer index; + + @JsonProperty("content_block") + private AnthropicContent contentBlock; + + // For content_block_delta + @JsonProperty("delta") + private Delta delta; + + // For message_delta + @JsonProperty("usage") + private AnthropicUsage usage; + + public AnthropicStreamEvent() {} + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public AnthropicResponse getMessage() { + return message; + } + + public void setMessage(AnthropicResponse message) { + this.message = message; + } + + public Integer getIndex() { + return index; + } + + public void setIndex(Integer index) { + this.index = index; + } + + public AnthropicContent getContentBlock() { + return contentBlock; + } + + public void setContentBlock(AnthropicContent contentBlock) { + this.contentBlock = contentBlock; + } + + public Delta getDelta() { + return delta; + } + + public void setDelta(Delta delta) { + this.delta = delta; + } + + public AnthropicUsage getUsage() { + return usage; + } + + public void setUsage(AnthropicUsage usage) { + this.usage = usage; + } + + /** + * Represents a delta update in streaming events. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Delta { + @JsonProperty("type") + private String type; + + @JsonProperty("text") + private String text; + + @JsonProperty("stop_reason") + private String stopReason; + + @JsonProperty("stop_sequence") + private String stopSequence; + + @JsonProperty("partial_json") + private String partialJson; + + public Delta() {} + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public String getStopReason() { + return stopReason; + } + + public void setStopReason(String stopReason) { + this.stopReason = stopReason; + } + + public String getStopSequence() { + return stopSequence; + } + + public void setStopSequence(String stopSequence) { + this.stopSequence = stopSequence; + } + + public String getPartialJson() { + return partialJson; + } + + public void setPartialJson(String partialJson) { + this.partialJson = partialJson; + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicTool.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicTool.java new file mode 100644 index 000000000..95e88c806 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicTool.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * Represents a tool definition in an Anthropic API request. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicTool { + + @JsonProperty("name") + private String name; + + @JsonProperty("description") + private String description; + + @JsonProperty("input_schema") + private Map inputSchema; + + public AnthropicTool() {} + + public AnthropicTool(String name, String description, Map inputSchema) { + this.name = name; + this.description = description; + this.inputSchema = inputSchema; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Map getInputSchema() { + return inputSchema; + } + + public void setInputSchema(Map inputSchema) { + this.inputSchema = inputSchema; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicUsage.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicUsage.java new file mode 100644 index 000000000..f2f440c96 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/anthropic/dto/AnthropicUsage.java @@ -0,0 +1,72 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents usage statistics in an Anthropic API response. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AnthropicUsage { + + @JsonProperty("input_tokens") + private Integer inputTokens; + + @JsonProperty("output_tokens") + private Integer outputTokens; + + @JsonProperty("cache_creation_input_tokens") + private Integer cacheCreationInputTokens; + + @JsonProperty("cache_read_input_tokens") + private Integer cacheReadInputTokens; + + public AnthropicUsage() {} + + public Integer getInputTokens() { + return inputTokens; + } + + public void setInputTokens(Integer inputTokens) { + this.inputTokens = inputTokens; + } + + public Integer getOutputTokens() { + return outputTokens; + } + + public void setOutputTokens(Integer outputTokens) { + this.outputTokens = outputTokens; + } + + public Integer getCacheCreationInputTokens() { + return cacheCreationInputTokens; + } + + public void setCacheCreationInputTokens(Integer cacheCreationInputTokens) { + this.cacheCreationInputTokens = cacheCreationInputTokens; + } + + public Integer getCacheReadInputTokens() { + return cacheReadInputTokens; + } + + public void setCacheReadInputTokens(Integer cacheReadInputTokens) { + this.cacheReadInputTokens = cacheReadInputTokens; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/AnthropicChatModel.java b/agentscope-core/src/main/java/io/agentscope/core/model/AnthropicChatModel.java index 6a7a348af..b1cf2c773 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/AnthropicChatModel.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/AnthropicChatModel.java @@ -15,16 +15,14 @@ */ package io.agentscope.core.model; -import com.anthropic.client.AnthropicClient; -import com.anthropic.client.okhttp.AnthropicOkHttpClient; -import com.anthropic.core.http.StreamResponse; -import com.anthropic.models.messages.MessageCreateParams; -import com.anthropic.models.messages.MessageParam; -import com.anthropic.models.messages.RawMessageStreamEvent; import io.agentscope.core.formatter.anthropic.AnthropicBaseFormatter; import io.agentscope.core.formatter.anthropic.AnthropicChatFormatter; import io.agentscope.core.formatter.anthropic.AnthropicResponseParser; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; +import io.agentscope.core.formatter.anthropic.dto.AnthropicRequest; import io.agentscope.core.message.Msg; +import io.agentscope.core.model.transport.HttpTransport; +import io.agentscope.core.model.transport.HttpTransportFactory; import java.time.Instant; import java.util.List; import org.slf4j.Logger; @@ -34,7 +32,7 @@ import reactor.core.scheduler.Schedulers; /** - * Anthropic Chat Model implementation using the official Anthropic Java SDK. + * Anthropic Chat Model implementation using native HTTP client via OkHttp. * *

* This implementation provides complete integration with Anthropic's Messages @@ -74,6 +72,7 @@ public class AnthropicChatModel extends ChatModelBase { * @param defaultOptions default generation options * @param formatter the message formatter to use (null for default * Anthropic formatter) + * @param httpTransport the HTTP transport to use (null for default) */ public AnthropicChatModel( String baseUrl, @@ -81,7 +80,8 @@ public AnthropicChatModel( String modelName, boolean streamEnabled, GenerateOptions defaultOptions, - AnthropicBaseFormatter formatter) { + AnthropicBaseFormatter formatter, + HttpTransport httpTransport) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.modelName = modelName; @@ -91,17 +91,9 @@ public AnthropicChatModel( this.formatter = formatter != null ? formatter : new AnthropicChatFormatter(); // Initialize Anthropic client - AnthropicOkHttpClient.Builder clientBuilder = AnthropicOkHttpClient.builder(); - - if (apiKey != null) { - clientBuilder.apiKey(apiKey); - } - - if (baseUrl != null) { - clientBuilder.baseUrl(baseUrl); - } - - this.client = clientBuilder.build(); + HttpTransport transport = + httpTransport != null ? httpTransport : HttpTransportFactory.getDefault(); + this.client = new AnthropicClient(transport); } /** @@ -125,6 +117,7 @@ public AnthropicChatModel( protected Flux doStream( List messages, List tools, GenerateOptions options) { Instant startTime = Instant.now(); + GenerateOptions effectiveOptions = GenerateOptions.mergeOptions(options, defaultOptions); log.debug( "Anthropic stream: model={}, messages={}, tools_present={}", modelName, @@ -135,64 +128,52 @@ protected Flux doStream( Flux.defer( () -> { try { - // Build message create params - MessageCreateParams.Builder paramsBuilder = - MessageCreateParams.builder() - .model(modelName) - .maxTokens(4096); + // Build Anthropic request + AnthropicRequest request = new AnthropicRequest(); + request.setModel(modelName); + request.setMaxTokens(4096); - // Extract and apply system message - // (Anthropic-specific requirement) - formatter.applySystemMessage(paramsBuilder, messages); + // Extract and apply system message (Anthropic-specific requirement) + formatter.applySystemMessage(request, messages); - // Use formatter to convert Msg to Anthropic - // MessageParam - List formattedMessages = formatter.format(messages); - for (MessageParam param : formattedMessages) { - paramsBuilder.addMessage(param); - } + // Use formatter to convert Msg to Anthropic messages + List formattedMessages = + formatter.format(messages); + request.setMessages(formattedMessages); // Apply generation options via formatter - formatter.applyOptions(paramsBuilder, options, defaultOptions); + formatter.applyOptions(request, effectiveOptions, null); // Add tools if provided if (tools != null && !tools.isEmpty()) { - formatter.applyTools(paramsBuilder, tools); + formatter.applyTools(request, tools); + if (effectiveOptions != null + && effectiveOptions.getToolChoice() != null) { + formatter.applyToolChoice( + request, effectiveOptions.getToolChoice()); + } } - // Create the request - MessageCreateParams params = paramsBuilder.build(); - if (streamEnabled) { // Make streaming API call - StreamResponse streamResponse = - client.messages().createStreaming(params); - - // Convert the SDK's Stream to Flux return AnthropicResponseParser.parseStreamEvents( - Flux.fromStream(streamResponse.stream()) - .subscribeOn( - Schedulers.boundedElastic()), - startTime) - .doFinally( - signalType -> { - try { - streamResponse.close(); - } catch (Exception e) { - log.debug( - "Error closing stream" - + " response", - e); - } - }); + client.stream( + apiKey, baseUrl, request, effectiveOptions), + startTime); } else { // For non-streaming, make a single call - // via CompletableFuture - return Mono.fromFuture(client.async().messages().create(params)) + return Mono.fromCallable( + () -> + client.call( + apiKey, + baseUrl, + request, + effectiveOptions)) + .subscribeOn(Schedulers.boundedElastic()) .map( - message -> + response -> formatter.parseResponse( - message, startTime)) + response, startTime)) .flux(); } } catch (Exception e) { @@ -207,7 +188,7 @@ protected Flux doStream( // Apply timeout and retry if configured return ModelUtils.applyTimeoutAndRetry( - responseFlux, options, defaultOptions, modelName, "anthropic"); + responseFlux, effectiveOptions, null, modelName, "anthropic"); } /** @@ -237,6 +218,7 @@ public static class Builder { private boolean streamEnabled = true; private GenerateOptions defaultOptions; private AnthropicBaseFormatter formatter; + private HttpTransport httpTransport; /** * Sets the base URL for the Anthropic API. @@ -304,6 +286,17 @@ public Builder formatter(AnthropicBaseFormatter formatter) { return this; } + /** + * Sets the HTTP transport to use. + * + * @param httpTransport the HTTP transport + * @return this builder + */ + public Builder httpTransport(HttpTransport httpTransport) { + this.httpTransport = httpTransport; + return this; + } + /** * Builds the AnthropicChatModel instance. * @@ -311,7 +304,13 @@ public Builder formatter(AnthropicBaseFormatter formatter) { */ public AnthropicChatModel build() { return new AnthropicChatModel( - baseUrl, apiKey, modelName, streamEnabled, defaultOptions, formatter); + baseUrl, + apiKey, + modelName, + streamEnabled, + defaultOptions, + formatter, + httpTransport); } } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/AnthropicClient.java b/agentscope-core/src/main/java/io/agentscope/core/model/AnthropicClient.java new file mode 100644 index 000000000..56912e424 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/model/AnthropicClient.java @@ -0,0 +1,407 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.model; + +import io.agentscope.core.Version; +import io.agentscope.core.formatter.anthropic.dto.AnthropicRequest; +import io.agentscope.core.formatter.anthropic.dto.AnthropicResponse; +import io.agentscope.core.formatter.anthropic.dto.AnthropicStreamEvent; +import io.agentscope.core.model.transport.HttpRequest; +import io.agentscope.core.model.transport.HttpResponse; +import io.agentscope.core.model.transport.HttpTransport; +import io.agentscope.core.model.transport.HttpTransportException; +import io.agentscope.core.util.JsonException; +import io.agentscope.core.util.JsonUtils; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +/** + * Stateless HTTP client for Anthropic's Messages API. + * + *

+ * This client handles communication with Anthropic's API using direct HTTP + * calls via OkHttp. + * All configuration (API key, base URL) is passed per-request, making this + * client stateless and + * safe to share across multiple model instances. + * + *

+ * Features: + *

    + *
  • Synchronous and streaming request support
  • + *
  • SSE stream parsing
  • + *
  • JSON serialization/deserialization
  • + *
  • Support for custom base URLs
  • + *
+ */ +public class AnthropicClient { + + private static final Logger log = LoggerFactory.getLogger(AnthropicClient.class); + + /** Default base URL for Anthropic API. */ + public static final String DEFAULT_BASE_URL = "https://api.anthropic.com"; + + /** Messages API endpoint. */ + public static final String MESSAGES_ENDPOINT = "/v1/messages"; + + /** Default Anthropic API version. */ + public static final String DEFAULT_API_VERSION = "2023-06-01"; + + private final HttpTransport transport; + + /** + * Create a new stateless AnthropicClient. + * + * @param transport the HTTP transport to use + */ + public AnthropicClient(HttpTransport transport) { + this.transport = transport; + } + + /** + * Create a new AnthropicClient with the default transport from factory. + */ + public AnthropicClient() { + this(io.agentscope.core.model.transport.HttpTransportFactory.getDefault()); + } + + /** + * Normalize the base URL by removing trailing slashes. + * + * @param url the base URL to normalize + * @return the normalized base URL (trailing slash removed) + */ + private static String normalizeBaseUrl(String url) { + if (url == null || url.isEmpty()) { + return url; + } + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + /** + * Get the effective base URL (options baseUrl or default). + * + * @param baseUrl the base URL from options + * @return the effective base URL + */ + private String getEffectiveBaseUrl(String baseUrl) { + if (baseUrl != null && !baseUrl.isEmpty()) { + return normalizeBaseUrl(baseUrl); + } + return DEFAULT_BASE_URL; + } + + /** + * Get the effective API key (options apiKey or null). + * + * @param apiKey the API key from options + * @return the effective API key + */ + private String getEffectiveApiKey(String apiKey) { + return apiKey; + } + + /** + * Build the complete API URL. + * + * @param baseUrl the base URL + * @return the complete API URL + */ + private String buildApiUrl(String baseUrl) { + return baseUrl + MESSAGES_ENDPOINT; + } + + /** + * Make a synchronous API call. + * + * @param apiKey the API key for authentication + * @param baseUrl the base URL (null for default) + * @param request the Anthropic request + * @return the Anthropic response + * @throws ModelException if the request fails + */ + public AnthropicResponse call(String apiKey, String baseUrl, AnthropicRequest request) { + return call(apiKey, baseUrl, request, null); + } + + /** + * Make a synchronous API call with options. + * + * @param apiKey the API key for authentication + * @param baseUrl the base URL (null for default) + * @param request the Anthropic request + * @param options additional options for headers + * @return the Anthropic response + * @throws ModelException if the request fails + */ + public AnthropicResponse call( + String apiKey, String baseUrl, AnthropicRequest request, GenerateOptions options) { + Objects.requireNonNull(request, "Request cannot be null"); + + String effectiveBaseUrl = getEffectiveBaseUrl(baseUrl); + String effectiveApiKey = getEffectiveApiKey(apiKey); + + // Allow options to override apiKey and baseUrl + if (options != null) { + if (options.getApiKey() != null) { + effectiveApiKey = options.getApiKey(); + } + if (options.getBaseUrl() != null) { + effectiveBaseUrl = getEffectiveBaseUrl(options.getBaseUrl()); + } + } + + String url = buildApiUrl(effectiveBaseUrl); + + try { + // Ensure stream is false for non-streaming call + request.setStream(false); + + String requestBody = JsonUtils.getJsonCodec().toJson(request); + log.debug("Anthropic request to {}: {}", url, requestBody); + + HttpRequest httpRequest = + HttpRequest.builder() + .url(url) + .method("POST") + .headers(buildHeaders(effectiveApiKey, options)) + .body(requestBody) + .build(); + + HttpResponse httpResponse = execute(httpRequest); + + if (!httpResponse.isSuccessful()) { + int statusCode = httpResponse.getStatusCode(); + String responseBody = httpResponse.getBody(); + String errorMessage = + "Anthropic API request failed with status " + + statusCode + + " | " + + responseBody; + throw new ModelException(errorMessage, null, request.getModel(), "anthropic"); + } + + String responseBody = httpResponse.getBody(); + if (responseBody == null || responseBody.isEmpty()) { + throw new ModelException( + "Anthropic API returned empty response body", + null, + request.getModel(), + "anthropic"); + } + log.debug("Anthropic response: {}", responseBody); + + AnthropicResponse response; + try { + response = JsonUtils.getJsonCodec().fromJson(responseBody, AnthropicResponse.class); + } catch (JsonException e) { + throw new ModelException( + "Failed to parse Anthropic response: " + + e.getMessage() + + ". Response body: " + + responseBody, + e, + request.getModel(), + "anthropic"); + } + + // Defensive null check after deserialization + if (response == null) { + throw new ModelException( + "Anthropic API returned null response after deserialization", + null, + request.getModel(), + "anthropic"); + } + + return response; + } catch (JsonException | HttpTransportException e) { + throw new ModelException( + "Failed to execute request: " + e.getMessage(), + e, + request.getModel(), + "anthropic"); + } + } + + /** + * Make a streaming API call. + * + * @param apiKey the API key for authentication + * @param baseUrl the base URL (null for default) + * @param request the Anthropic request + * @param options generation options containing additional headers + * @return a Flux of Anthropic stream events + */ + public Flux stream( + String apiKey, String baseUrl, AnthropicRequest request, GenerateOptions options) { + Objects.requireNonNull(request, "Request cannot be null"); + + String effectiveBaseUrl = getEffectiveBaseUrl(baseUrl); + String effectiveApiKey = getEffectiveApiKey(apiKey); + + // Allow options to override apiKey and baseUrl + if (options != null) { + if (options.getApiKey() != null) { + effectiveApiKey = options.getApiKey(); + } + if (options.getBaseUrl() != null) { + effectiveBaseUrl = getEffectiveBaseUrl(options.getBaseUrl()); + } + } + + String url = buildApiUrl(effectiveBaseUrl); + + try { + // Enable streaming + request.setStream(true); + + String requestBody = JsonUtils.getJsonCodec().toJson(request); + log.debug("Anthropic streaming request to {}: {}", url, requestBody); + + HttpRequest httpRequest = + HttpRequest.builder() + .url(url) + .method("POST") + .headers(buildHeaders(effectiveApiKey, options)) + .body(requestBody) + .build(); + + return transport.stream(httpRequest) + .filter(data -> !data.equals("[DONE]")) + .handle( + (data, sink) -> { + AnthropicStreamEvent event = parseStreamData(data); + if (event != null) { + sink.next(event); + } + // If event is null (malformed chunk), skip it silently + }) + .onErrorMap( + ex -> { + if (ex instanceof HttpTransportException) { + return new ModelException( + "HTTP transport error during streaming: " + + ex.getMessage(), + ex, + request.getModel(), + "anthropic"); + } + return ex; + }); + } catch (JsonException | HttpTransportException e) { + return Flux.error( + new ModelException( + "Failed to initialize request: " + e.getMessage(), + e, + request.getModel(), + "anthropic")); + } + } + + /** + * Parse a single SSE data line to AnthropicStreamEvent. + * + * @param data the SSE data (without "data: " prefix) + * @return the parsed AnthropicStreamEvent, or null if parsing fails + */ + private AnthropicStreamEvent parseStreamData(String data) { + if (log.isDebugEnabled()) { + log.debug("SSE data: {}", data); + } + try { + if (data == null || data.isEmpty()) { + log.debug("Ignoring empty SSE data"); + return null; + } + AnthropicStreamEvent event = + JsonUtils.getJsonCodec().fromJson(data, AnthropicStreamEvent.class); + + // Defensive null check after deserialization + if (event == null) { + log.warn( + "AnthropicStreamEvent deserialization returned null for data: {}", + data.length() > 100 ? data.substring(0, 100) + "..." : data); + return null; + } + return event; + } catch (JsonException e) { + log.error( + "Failed to parse SSE data - JSON error: {}. Content: {}.", + e.getMessage(), + data != null && data.length() > 100 ? data.substring(0, 100) + "..." : data); + return null; + } catch (Exception e) { + log.warn("Failed to parse SSE data - unexpected error: {}", e.getMessage(), e); + return null; + } + } + + /** + * Build HTTP headers for API requests. + * + * @param apiKey the API key for authentication + * @param options additional options for headers + * @return map of headers + */ + private Map buildHeaders(String apiKey, GenerateOptions options) { + Map headers = new HashMap<>(); + if (apiKey != null && !apiKey.isEmpty()) { + headers.put("x-api-key", apiKey); + } + headers.put("Content-Type", "application/json"); + headers.put("anthropic-version", DEFAULT_API_VERSION); + + // Add User-Agent header + String userAgent = Version.getUserAgent(); + headers.put("User-Agent", userAgent); + + // Apply additional headers from options + if (options != null) { + Map additionalHeaders = options.getAdditionalHeaders(); + if (additionalHeaders != null && !additionalHeaders.isEmpty()) { + headers.putAll(additionalHeaders); + } + } + + return headers; + } + + /** + * Execute HTTP request without internal retry (retry is handled by Model + * layer). + * + * @param request the HTTP request + * @return the HTTP response + * @throws HttpTransportException if execution fails + */ + HttpResponse execute(HttpRequest request) throws HttpTransportException { + return transport.execute(request); + } + + /** + * Get the HTTP transport used by this client. + * + * @return the HTTP transport + */ + public HttpTransport getTransport() { + return transport; + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterGroundTruthTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterGroundTruthTest.java index ad988b4eb..ba379531d 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterGroundTruthTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterGroundTruthTest.java @@ -16,10 +16,14 @@ package io.agentscope.core.formatter.anthropic; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -import com.anthropic.core.ObjectMappers; -import com.anthropic.models.messages.MessageParam; import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; +import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; @@ -35,7 +39,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -/** Ground truth tests for AnthropicChatFormatter - compares with Python implementation. */ +/** + * Ground truth tests for AnthropicChatFormatter - compares with expected JSON + * structure. + */ class AnthropicChatFormatterGroundTruthTest { private AnthropicChatFormatter formatter; @@ -46,11 +53,29 @@ class AnthropicChatFormatterGroundTruthTest { private List msgsTools; @BeforeEach - void setUp() { - formatter = new AnthropicChatFormatter(); + void setUp() throws Exception { jsonCodec = JsonUtils.getJsonCodec(); imageUrl = "https://www.example.com/image.png"; + // Mock media converter to return fixed base64 + AnthropicMediaConverter mediaConverter = mock(AnthropicMediaConverter.class); + when(mediaConverter.convertImageBlock(any())) + .thenReturn(new AnthropicContent.ImageSource("image/png", "fake_base64_data")); + + // Use custom converter with mocked media converter + AnthropicMessageConverter messageConverter = + new AnthropicMessageConverter( + blocks -> { + StringBuilder sb = new StringBuilder(); + for (ContentBlock b : blocks) { + if (b instanceof TextBlock tb) sb.append(tb.getText()); + } + return sb.toString(); + }, + mediaConverter); + + formatter = new AnthropicChatFormatter(messageConverter); + // System message msgsSystem = List.of( @@ -127,8 +152,9 @@ void setUp() { TextBlock.builder() .text( "The capital of" - + " Japan is" - + " Tokyo.") + + " Japan" + + " is" + + " Tokyo.") .build()) .build())) .build(), @@ -144,20 +170,20 @@ void setUp() { } @Test - void testChatFormatterFullHistory() throws Exception { + void testChatFormatterFullHistory() { // Full history: system + conversation + tools List allMsgs = new ArrayList<>(); allMsgs.addAll(msgsSystem); allMsgs.addAll(msgsConversation); allMsgs.addAll(msgsTools); - List result = formatter.format(allMsgs); + List result = formatter.format(allMsgs); // Convert to JSON string for comparison - String resultJson = ObjectMappers.jsonMapper().writeValueAsString(result); + String resultJson = jsonCodec.toJson(result); JsonNode resultNode = jsonCodec.fromJson(resultJson, JsonNode.class); - // Ground truth from Python implementation + // Ground truth String groundTruthJson = """ [ @@ -180,8 +206,9 @@ void testChatFormatterFullHistory() throws Exception { { "type": "image", "source": { - "type": "url", - "url": "https://www.example.com/image.png" + "type": "base64", + "media_type": "image/png", + "data": "fake_base64_data" } } ] @@ -208,8 +235,8 @@ void testChatFormatterFullHistory() throws Exception { "role": "assistant", "content": [ { - "id": "1", "type": "tool_use", + "id": "1", "name": "get_capital", "input": { "country": "Japan" @@ -250,14 +277,14 @@ void testChatFormatterFullHistory() throws Exception { } @Test - void testChatFormatterWithoutSystemMessage() throws Exception { + void testChatFormatterWithoutSystemMessage() { // Without system message List allMsgs = new ArrayList<>(); allMsgs.addAll(msgsConversation); allMsgs.addAll(msgsTools); - List result = formatter.format(allMsgs); - String resultJson = ObjectMappers.jsonMapper().writeValueAsString(result); + List result = formatter.format(allMsgs); + String resultJson = jsonCodec.toJson(result); JsonNode resultNode = jsonCodec.fromJson(resultJson, JsonNode.class); // Ground truth should be the same as full history, but without first message @@ -274,8 +301,9 @@ void testChatFormatterWithoutSystemMessage() throws Exception { { "type": "image", "source": { - "type": "url", - "url": "https://www.example.com/image.png" + "type": "base64", + "media_type": "image/png", + "data": "fake_base64_data" } } ] @@ -302,8 +330,8 @@ void testChatFormatterWithoutSystemMessage() throws Exception { "role": "assistant", "content": [ { - "id": "1", "type": "tool_use", + "id": "1", "name": "get_capital", "input": { "country": "Japan" @@ -343,14 +371,14 @@ void testChatFormatterWithoutSystemMessage() throws Exception { } @Test - void testChatFormatterWithoutConversation() throws Exception { + void testChatFormatterWithoutConversation() { // Without conversation messages: system + tools only List allMsgs = new ArrayList<>(); allMsgs.addAll(msgsSystem); allMsgs.addAll(msgsTools); - List result = formatter.format(allMsgs); - String resultJson = ObjectMappers.jsonMapper().writeValueAsString(result); + List result = formatter.format(allMsgs); + String resultJson = jsonCodec.toJson(result); JsonNode resultNode = jsonCodec.fromJson(resultJson, JsonNode.class); String groundTruthJson = @@ -369,8 +397,8 @@ void testChatFormatterWithoutConversation() throws Exception { "role": "assistant", "content": [ { - "id": "1", "type": "tool_use", + "id": "1", "name": "get_capital", "input": { "country": "Japan" @@ -410,14 +438,14 @@ void testChatFormatterWithoutConversation() throws Exception { } @Test - void testChatFormatterWithoutTools() throws Exception { + void testChatFormatterWithoutTools() { // Without tool messages: system + conversation only List allMsgs = new ArrayList<>(); allMsgs.addAll(msgsSystem); allMsgs.addAll(msgsConversation); - List result = formatter.format(allMsgs); - String resultJson = ObjectMappers.jsonMapper().writeValueAsString(result); + List result = formatter.format(allMsgs); + String resultJson = jsonCodec.toJson(result); JsonNode resultNode = jsonCodec.fromJson(resultJson, JsonNode.class); String groundTruthJson = @@ -442,8 +470,9 @@ void testChatFormatterWithoutTools() throws Exception { { "type": "image", "source": { - "type": "url", - "url": "https://www.example.com/image.png" + "type": "base64", + "media_type": "image/png", + "data": "fake_base64_data" } } ] @@ -475,7 +504,7 @@ void testChatFormatterWithoutTools() throws Exception { @Test void testChatFormatterEmptyMessages() { - List result = formatter.format(List.of()); + List result = formatter.format(List.of()); assertEquals(0, result.size(), "Empty input should produce empty output"); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterTest.java index 8a6e89fc3..aac8835b4 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicChatFormatterTest.java @@ -17,17 +17,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.anthropic.models.messages.ContentBlockParam; -import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.MessageCreateParams; -import com.anthropic.models.messages.MessageParam; -import com.anthropic.models.messages.TextBlockParam; -import com.anthropic.models.messages.Usage; + +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; +import io.agentscope.core.formatter.anthropic.dto.AnthropicRequest; +import io.agentscope.core.formatter.anthropic.dto.AnthropicResponse; +import io.agentscope.core.formatter.anthropic.dto.AnthropicUsage; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; @@ -39,7 +35,7 @@ import io.agentscope.core.model.ToolSchema; import java.time.Instant; import java.util.List; -import java.util.Optional; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -62,11 +58,11 @@ void testFormatSimpleUserMessage() { .content(List.of(TextBlock.builder().text("Hello").build())) .build(); - List result = formatter.format(List.of(msg)); + List result = formatter.format(List.of(msg)); assertNotNull(result); assertEquals(1, result.size()); - assertEquals(MessageParam.Role.USER, result.get(0).role()); + assertEquals("user", result.get(0).getRole()); } @Test @@ -78,12 +74,11 @@ void testFormatSystemMessage() { .content(List.of(TextBlock.builder().text("You are helpful").build())) .build(); - List result = formatter.format(List.of(msg)); + List result = formatter.format(List.of(msg)); assertNotNull(result); assertEquals(1, result.size()); - // First system message converted to USER - assertEquals(MessageParam.Role.USER, result.get(0).role()); + assertEquals("user", result.get(0).getRole()); } @Test @@ -102,16 +97,16 @@ void testFormatMultipleMessages() { .content(List.of(TextBlock.builder().text("Hi").build())) .build(); - List result = formatter.format(List.of(userMsg, assistantMsg)); + List result = formatter.format(List.of(userMsg, assistantMsg)); assertEquals(2, result.size()); - assertEquals(MessageParam.Role.USER, result.get(0).role()); - assertEquals(MessageParam.Role.ASSISTANT, result.get(1).role()); + assertEquals("user", result.get(0).getRole()); + assertEquals("assistant", result.get(1).getRole()); } @Test void testFormatEmptyMessageList() { - List result = formatter.format(List.of()); + List result = formatter.format(List.of()); assertNotNull(result); assertTrue(result.isEmpty()); @@ -119,48 +114,31 @@ void testFormatEmptyMessageList() { @Test void testParseResponseWithMessage() { - // Create mock Message - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - com.anthropic.models.messages.ContentBlock contentBlock = - mock(com.anthropic.models.messages.ContentBlock.class); - com.anthropic.models.messages.TextBlock textBlock = - mock(com.anthropic.models.messages.TextBlock.class); - - when(message.id()).thenReturn("msg_test"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(100L); - when(usage.outputTokens()).thenReturn(50L); - - when(contentBlock.text()).thenReturn(Optional.of(textBlock)); - when(contentBlock.toolUse()).thenReturn(Optional.empty()); - when(contentBlock.thinking()).thenReturn(Optional.empty()); - when(textBlock.text()).thenReturn("Response"); - - Instant startTime = Instant.now(); - ChatResponse response = formatter.parseResponse(message, startTime); + // Create mock Response using DTO + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_test"); + AnthropicContent content = AnthropicContent.text("Response"); + response.setContent(List.of(content)); - assertNotNull(response); - assertEquals("msg_test", response.getId()); - assertEquals(1, response.getContent().size()); - assertNotNull(response.getUsage()); - } + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(100); + usage.setOutputTokens(50); + response.setUsage(usage); - @Test - void testParseResponseWithInvalidType() { - // Pass non-Message object should throw exception - String invalidResponse = "not a message"; Instant startTime = Instant.now(); - - assertThrows( - IllegalArgumentException.class, - () -> formatter.parseResponse(invalidResponse, startTime)); + ChatResponse chatResponse = formatter.parseResponse(response, startTime); + + assertNotNull(chatResponse); + assertEquals("msg_test", chatResponse.getId()); + assertEquals(1, chatResponse.getContent().size()); + assertNotNull(chatResponse.getUsage()); + assertEquals(100L, chatResponse.getUsage().getInputTokens()); + assertEquals(50L, chatResponse.getUsage().getOutputTokens()); } @Test void testApplySystemMessage() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + AnthropicRequest request = new AnthropicRequest(); Msg systemMsg = Msg.builder() @@ -169,36 +147,14 @@ void testApplySystemMessage() { .content(List.of(TextBlock.builder().text("You are helpful").build())) .build(); - formatter.applySystemMessage(paramsBuilder, List.of(systemMsg)); - - // Build and verify system message was set - // Note: Anthropic requires at least one message, add a dummy message - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); + formatter.applySystemMessage(request, List.of(systemMsg)); - // System message should be present in params - // Note: We can't directly access the system field without building, - // but we can verify no exception was thrown - assertNotNull(params); + assertEquals("You are helpful", request.getSystem()); } @Test void testApplySystemMessageWithNoSystemMessage() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); + AnthropicRequest request = new AnthropicRequest(); Msg userMsg = Msg.builder() @@ -207,213 +163,84 @@ void testApplySystemMessageWithNoSystemMessage() { .content(List.of(TextBlock.builder().text("Hello").build())) .build(); - formatter.applySystemMessage(paramsBuilder, List.of(userMsg)); - - // Should handle gracefully with no system message - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); + formatter.applySystemMessage(request, List.of(userMsg)); - assertNotNull(params); + // System should remain null or empty + Object system = request.getSystem(); + assertTrue(system == null || (system instanceof String && ((String) system).isEmpty())); } @Test void testApplySystemMessageWithEmptyMessages() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - formatter.applySystemMessage(paramsBuilder, List.of()); - - // Should handle empty list gracefully - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); - - assertNotNull(params); + AnthropicRequest request = new AnthropicRequest(); + formatter.applySystemMessage(request, List.of()); + Object system = request.getSystem(); + assertTrue(system == null || (system instanceof String && ((String) system).isEmpty())); } @Test void testApplyOptions() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - + AnthropicRequest request = new AnthropicRequest(); GenerateOptions options = GenerateOptions.builder().temperature(0.7).maxTokens(2000).topP(0.9).build(); GenerateOptions defaultOptions = GenerateOptions.builder().build(); - formatter.applyOptions(paramsBuilder, options, defaultOptions); - - // Build params and verify no exception - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); + formatter.applyOptions(request, options, defaultOptions); - assertNotNull(params); + assertEquals(0.7, request.getTemperature()); + assertEquals(2000, request.getMaxTokens()); + assertEquals(0.9, request.getTopP()); } @Test void testApplyOptionsWithNullOptions() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - + AnthropicRequest request = new AnthropicRequest(); GenerateOptions defaultOptions = GenerateOptions.builder().temperature(0.5).maxTokens(1024).build(); - formatter.applyOptions(paramsBuilder, null, defaultOptions); - - // Should use default options - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); + formatter.applyOptions(request, null, defaultOptions); - assertNotNull(params); + assertEquals(0.5, request.getTemperature()); + assertEquals(1024, request.getMaxTokens()); } @Test void testApplyTools() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - + AnthropicRequest request = new AnthropicRequest(); ToolSchema searchTool = ToolSchema.builder() .name("search") .description("Search the web") - .parameters( - java.util.Map.of( - "type", "object", "properties", java.util.Map.of())) + .parameters(Map.of("type", "object", "properties", Map.of())) .build(); + formatter.applyTools(request, List.of(searchTool)); + formatter.applyToolChoice(request, new ToolChoice.Auto()); - // First set options, then apply tools (tools need options for tool_choice) - GenerateOptions options = - GenerateOptions.builder().toolChoice(new ToolChoice.Auto()).build(); - - formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); - formatter.applyTools(paramsBuilder, List.of(searchTool)); - - // Build params and verify no exception - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); + assertNotNull(request.getTools()); + assertEquals(1, request.getTools().size()); + assertEquals("search", request.getTools().get(0).getName()); - assertNotNull(params); + assertNotNull(request.getToolChoice()); + @SuppressWarnings("unchecked") + Map toolChoice = (Map) request.getToolChoice(); + assertEquals("auto", toolChoice.get("type")); } @Test void testApplyToolsWithEmptyList() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - GenerateOptions options = GenerateOptions.builder().build(); - formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); - formatter.applyTools(paramsBuilder, List.of()); - - // Should handle empty tools gracefully - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); + AnthropicRequest request = new AnthropicRequest(); + formatter.applyTools(request, List.of()); - assertNotNull(params); + assertTrue(request.getTools() == null || request.getTools().isEmpty()); } @Test void testApplyToolsWithNullList() { - MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); - - GenerateOptions options = GenerateOptions.builder().build(); - formatter.applyOptions(paramsBuilder, options, GenerateOptions.builder().build()); - formatter.applyTools(paramsBuilder, null); - - // Should handle null tools gracefully - MessageCreateParams params = - paramsBuilder - .model("claude-3-5-sonnet-20241022") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text("test") - .build())))) - .build()) - .build(); + AnthropicRequest request = new AnthropicRequest(); + formatter.applyTools(request, null); - assertNotNull(params); + assertTrue(request.getTools() == null); } @Test @@ -427,14 +254,16 @@ void testFormatWithToolUseMessage() { ToolUseBlock.builder() .id("tool_123") .name("search") - .input(java.util.Map.of("query", "test")) + .input(Map.of("query", "test")) .build())) .build(); - List result = formatter.format(List.of(msg)); + List result = formatter.format(List.of(msg)); assertEquals(1, result.size()); - assertEquals(MessageParam.Role.ASSISTANT, result.get(0).role()); + assertEquals("assistant", result.get(0).getRole()); + assertEquals(1, result.get(0).getContent().size()); + assertEquals("tool_use", result.get(0).getContent().get(0).getType()); } @Test @@ -452,10 +281,155 @@ void testFormatWithToolResultMessage() { .build())) .build(); - List result = formatter.format(List.of(msg)); + List result = formatter.format(List.of(msg)); assertEquals(1, result.size()); // Tool results are converted to USER messages - assertEquals(MessageParam.Role.USER, result.get(0).role()); + assertEquals("user", result.get(0).getRole()); + assertEquals(1, result.get(0).getContent().size()); + assertEquals("tool_result", result.get(0).getContent().get(0).getType()); + } + + @Test + void testMultiAgentConversationDetection() { + // Create a multi-agent conversation with multiple assistant messages + Msg user1 = + Msg.builder() + .name("User") + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("Hello").build())) + .build(); + + Msg assistant1 = + Msg.builder() + .name("Analyst1") + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text("Analysis 1").build())) + .build(); + + Msg assistant2 = + Msg.builder() + .name("Analyst2") + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text("Analysis 2").build())) + .build(); + + List result = formatter.format(List.of(user1, assistant1, assistant2)); + + // Should merge into fewer messages (multi-agent detection) + assertNotNull(result); + assertTrue(result.size() >= 1); + } + + @Test + void testMultiAgentConversationWithSystemNamedUserMessage() { + // Test MsgHub announcement pattern (USER message with name="system") + Msg announcement = + Msg.builder() + .name("system") + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("Conversation started").build())) + .build(); + + Msg analyst1 = + Msg.builder() + .name("Analyst1") + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text("Response 1").build())) + .build(); + + Msg analyst2 = + Msg.builder() + .name("Analyst2") + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text("Response 2").build())) + .build(); + + List result = formatter.format(List.of(announcement, analyst1, analyst2)); + + // Should detect multi-agent scenario and merge appropriately + assertNotNull(result); + assertTrue(result.size() >= 1); + } + + @Test + void testMultiAgentConversationWithToolCalls() { + // Test that tool calls are preserved during multi-agent formatting + Msg analyst1 = + Msg.builder() + .name("Analyst1") + .role(MsgRole.ASSISTANT) + .content( + List.of( + ToolUseBlock.builder() + .id("tool_1") + .name("search") + .input(Map.of("query", "test")) + .build())) + .build(); + + Msg toolResult = + Msg.builder() + .name("Tool") + .role(MsgRole.TOOL) + .content( + List.of( + ToolResultBlock.builder() + .id("tool_1") + .name("search") + .output(TextBlock.builder().text("Result").build()) + .build())) + .build(); + + Msg analyst2 = + Msg.builder() + .name("Analyst2") + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text("Analysis complete").build())) + .build(); + + List result = formatter.format(List.of(analyst1, toolResult, analyst2)); + + // Tool sequence should be preserved + assertNotNull(result); + assertTrue(result.size() >= 2); + } + + @Test + void testSingleAgentConversationNotMerged() { + // Test that single-agent conversations are not merged + Msg user1 = + Msg.builder() + .name("User") + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("Hello").build())) + .build(); + + Msg assistant1 = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text("Hi").build())) + .build(); + + Msg user2 = + Msg.builder() + .name("User") + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("How are you?").build())) + .build(); + + Msg assistant2 = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text("Good").build())) + .build(); + + List result = + formatter.format(List.of(user1, assistant1, user2, assistant2)); + + // Should preserve all 4 messages (no merging needed for single-agent) + assertEquals(4, result.size()); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMediaConverterTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMediaConverterTest.java index 01c7911fa..27770310d 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMediaConverterTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMediaConverterTest.java @@ -18,15 +18,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import com.anthropic.models.messages.Base64ImageSource; -import com.anthropic.models.messages.ImageBlockParam; -import com.anthropic.models.messages.UrlImageSource; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.URLSource; import java.util.Base64; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; /** Unit tests for AnthropicMediaConverter. */ @@ -43,46 +41,37 @@ void testConvertImageBlockWithBase64Source() throws Exception { .build(); ImageBlock block = ImageBlock.builder().source(source).build(); - ImageBlockParam result = converter.convertImageBlock(block); + AnthropicContent.ImageSource result = converter.convertImageBlock(block); assertNotNull(result); - assertTrue(result.source().isBase64()); - - Base64ImageSource base64Source = result.source().asBase64(); - assertEquals("ZmFrZSBpbWFnZSBjb250ZW50", base64Source.data()); - assertEquals("image/png", base64Source.mediaType().toString()); + assertEquals("base64", result.getType()); + assertEquals("ZmFrZSBpbWFnZSBjb250ZW50", result.getData()); + assertEquals("image/png", result.getMediaType()); } @Test void testConvertImageBlockWithURLSourceLocal() throws Exception { - URLSource source = URLSource.builder().url(tempImageFile.toString()).build(); + URLSource source = + URLSource.builder().url(tempImageFile.toAbsolutePath().toString()).build(); ImageBlock block = ImageBlock.builder().source(source).build(); - ImageBlockParam result = converter.convertImageBlock(block); + AnthropicContent.ImageSource result = converter.convertImageBlock(block); assertNotNull(result); - assertTrue(result.source().isBase64()); + assertEquals("base64", result.getType()); + assertNotNull(result.getData()); - Base64ImageSource base64Source = result.source().asBase64(); - assertNotNull(base64Source.data()); // Verify it's valid base64 - byte[] decoded = Base64.getDecoder().decode(base64Source.data()); + byte[] decoded = Base64.getDecoder().decode(result.getData()); assertEquals("fake image content", new String(decoded)); } @Test - void testConvertImageBlockWithURLSourceRemote() throws Exception { - String remoteUrl = "https://example.com/image.png"; - URLSource source = URLSource.builder().url(remoteUrl).build(); - ImageBlock block = ImageBlock.builder().source(source).build(); - - ImageBlockParam result = converter.convertImageBlock(block); - - assertNotNull(result); - assertTrue(result.source().isUrl()); - - UrlImageSource urlSource = result.source().asUrl(); - assertEquals(remoteUrl, urlSource.url()); + @Disabled( + "Requires network access and mocked MediaUtils. The new implementation always downloads" + + " remote URLs.") + void testConvertImageBlockWithURLSourceRemote() { + // This test is disabled because it tries to download from example.com } @Test @@ -118,10 +107,10 @@ void testBase64EncodingDecoding() throws Exception { Base64Source.builder().data(base64Encoded).mediaType("image/png").build(); ImageBlock block = ImageBlock.builder().source(source).build(); - ImageBlockParam result = converter.convertImageBlock(block); - Base64ImageSource base64Source = result.source().asBase64(); + AnthropicContent.ImageSource result = converter.convertImageBlock(block); - byte[] decoded = Base64.getDecoder().decode(base64Source.data()); + assertEquals("base64", result.getType()); + byte[] decoded = Base64.getDecoder().decode(result.getData()); assertEquals(originalText, new String(decoded)); } @@ -134,10 +123,9 @@ void testConvertImageBlockWithJpegMediaType() throws Exception { .build(); ImageBlock block = ImageBlock.builder().source(source).build(); - ImageBlockParam result = converter.convertImageBlock(block); + AnthropicContent.ImageSource result = converter.convertImageBlock(block); - Base64ImageSource base64Source = result.source().asBase64(); - assertEquals("image/jpeg", base64Source.mediaType().toString()); + assertEquals("image/jpeg", result.getMediaType()); } @Test @@ -149,10 +137,9 @@ void testConvertImageBlockWithWebpMediaType() throws Exception { .build(); ImageBlock block = ImageBlock.builder().source(source).build(); - ImageBlockParam result = converter.convertImageBlock(block); + AnthropicContent.ImageSource result = converter.convertImageBlock(block); - Base64ImageSource base64Source = result.source().asBase64(); - assertEquals("image/webp", base64Source.mediaType().toString()); + assertEquals("image/webp", result.getMediaType()); } @Test @@ -164,10 +151,9 @@ void testConvertImageBlockWithGifMediaType() throws Exception { .build(); ImageBlock block = ImageBlock.builder().source(source).build(); - ImageBlockParam result = converter.convertImageBlock(block); + AnthropicContent.ImageSource result = converter.convertImageBlock(block); - Base64ImageSource base64Source = result.source().asBase64(); - assertEquals("image/gif", base64Source.mediaType().toString()); + assertEquals("image/gif", result.getMediaType()); } // Custom source type for testing unsupported sources diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMessageConverterTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMessageConverterTest.java index ae698da75..30dddd38b 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMessageConverterTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMessageConverterTest.java @@ -16,13 +16,12 @@ package io.agentscope.core.formatter.anthropic; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.anthropic.models.messages.ContentBlockParam; -import com.anthropic.models.messages.MessageParam; -import com.anthropic.models.messages.ToolResultBlockParam; -import com.anthropic.models.messages.ToolUseBlockParam; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.ImageBlock; @@ -32,7 +31,6 @@ import io.agentscope.core.message.ThinkingBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; -import java.util.ArrayList; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -68,16 +66,16 @@ void testConvertSimpleUserMessage() { .content(List.of(TextBlock.builder().text("Hello").build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); assertEquals(1, result.size()); - MessageParam param = result.get(0); - assertEquals(MessageParam.Role.USER, param.role()); - assertTrue(param.content().isBlockParams()); - List blocks = param.content().asBlockParams(); - assertEquals(1, blocks.size()); - assertTrue(blocks.get(0).isText()); - assertEquals("Hello", blocks.get(0).asText().text()); + AnthropicMessage msgParam = result.get(0); + assertEquals("user", msgParam.getRole()); + + List contents = msgParam.getContent(); + assertEquals(1, contents.size()); + assertEquals("text", contents.get(0).getType()); + assertEquals("Hello", contents.get(0).getText()); } @Test @@ -89,11 +87,10 @@ void testConvertAssistantMessage() { .content(List.of(TextBlock.builder().text("Hi there").build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); assertEquals(1, result.size()); - MessageParam param = result.get(0); - assertEquals(MessageParam.Role.ASSISTANT, param.role()); + assertEquals("assistant", result.get(0).getRole()); } @Test @@ -105,36 +102,11 @@ void testConvertSystemMessageFirst() { .content(List.of(TextBlock.builder().text("System prompt").build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); + // In AnthropicMessageConverter.convertRole: SYSTEM -> "user" assertEquals(1, result.size()); - MessageParam param = result.get(0); - // First system message is converted to USER in Anthropic - assertEquals(MessageParam.Role.USER, param.role()); - } - - @Test - void testConvertSystemMessageNotFirst() { - Msg userMsg = - Msg.builder() - .name("User") - .role(MsgRole.USER) - .content(List.of(TextBlock.builder().text("Hello").build())) - .build(); - - Msg systemMsg = - Msg.builder() - .name("System") - .role(MsgRole.SYSTEM) - .content(List.of(TextBlock.builder().text("Note").build())) - .build(); - - List result = converter.convert(List.of(userMsg, systemMsg)); - - assertEquals(2, result.size()); - // Both converted to USER - assertEquals(MessageParam.Role.USER, result.get(0).role()); - assertEquals(MessageParam.Role.USER, result.get(1).role()); + assertEquals("user", result.get(0).getRole()); } @Test @@ -149,17 +121,17 @@ void testConvertMultipleTextBlocks() { TextBlock.builder().text("Second").build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); assertEquals(1, result.size()); - List blocks = result.get(0).content().asBlockParams(); + List blocks = result.get(0).getContent(); assertEquals(2, blocks.size()); - assertEquals("First", blocks.get(0).asText().text()); - assertEquals("Second", blocks.get(1).asText().text()); + assertEquals("First", blocks.get(0).getText()); + assertEquals("Second", blocks.get(1).getText()); } @Test - void testConvertImageBlock() { + void testConvertImageBlock() throws Exception { Base64Source source = Base64Source.builder() .data("ZmFrZSBpbWFnZSBjb250ZW50") @@ -173,12 +145,16 @@ void testConvertImageBlock() { .content(List.of(ImageBlock.builder().source(source).build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); assertEquals(1, result.size()); - List blocks = result.get(0).content().asBlockParams(); + List blocks = result.get(0).getContent(); assertEquals(1, blocks.size()); - assertTrue(blocks.get(0).isImage()); + assertEquals("image", blocks.get(0).getType()); + // For DTO verification + assertNotNull(blocks.get(0).getSource()); + assertEquals("base64", blocks.get(0).getSource().getType()); + assertEquals("image/png", blocks.get(0).getSource().getMediaType()); } @Test @@ -194,13 +170,13 @@ void testConvertThinkingBlock() { .build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); assertEquals(1, result.size()); - List blocks = result.get(0).content().asBlockParams(); + List blocks = result.get(0).getContent(); assertEquals(1, blocks.size()); - assertTrue(blocks.get(0).isText()); - assertEquals("Let me think...", blocks.get(0).asText().text()); + assertEquals("thinking", blocks.get(0).getType()); + assertEquals("Let me think...", blocks.get(0).getThinking()); } @Test @@ -219,17 +195,15 @@ void testConvertToolUseBlock() { .build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); assertEquals(1, result.size()); - List blocks = result.get(0).content().asBlockParams(); + List blocks = result.get(0).getContent(); assertEquals(1, blocks.size()); - assertTrue(blocks.get(0).isToolUse()); - - ToolUseBlockParam toolUse = blocks.get(0).asToolUse(); - assertEquals("call_123", toolUse.id()); - assertEquals("search", toolUse.name()); - // Note: input validation happens during API calls, not during conversion + assertEquals("tool_use", blocks.get(0).getType()); + assertEquals("call_123", blocks.get(0).getId()); + assertEquals("search", blocks.get(0).getName()); + assertEquals(input, blocks.get(0).getInput()); } @Test @@ -250,126 +224,29 @@ void testConvertToolResultBlockString() { .build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); // Tool result creates separate user message assertEquals(1, result.size()); - MessageParam param = result.get(0); - assertEquals(MessageParam.Role.USER, param.role()); + AnthropicMessage param = result.get(0); + assertEquals("user", param.getRole()); - List blocks = param.content().asBlockParams(); + List blocks = param.getContent(); assertEquals(1, blocks.size()); - assertTrue(blocks.get(0).isToolResult()); - - ToolResultBlockParam toolResult = blocks.get(0).asToolResult(); - assertEquals("call_123", toolResult.toolUseId()); - assertTrue(toolResult.content().isPresent()); - assertTrue(toolResult.content().get().isBlocks()); - } - - @Test - void testConvertToolResultBlockWithTextBlock() { - TextBlock textBlock = TextBlock.builder().text("Tool output").build(); - Msg msg = - Msg.builder() - .name("Tool") - .role(MsgRole.TOOL) - .content( - List.of( - ToolResultBlock.builder() - .id("call_123") - .name("search") - .output(textBlock) - .build())) - .build(); - - List result = converter.convert(List.of(msg)); - - assertEquals(1, result.size()); - MessageParam param = result.get(0); - assertEquals(MessageParam.Role.USER, param.role()); - - List blocks = param.content().asBlockParams(); - assertTrue(blocks.get(0).isToolResult()); - ToolResultBlockParam toolResult = blocks.get(0).asToolResult(); - assertTrue(toolResult.content().isPresent()); - assertTrue(toolResult.content().get().isBlocks()); - } - - @Test - void testConvertToolResultBlockMultiBlock() { - List outputBlocks = new ArrayList<>(); - outputBlocks.add(TextBlock.builder().text("First").build()); - outputBlocks.add(TextBlock.builder().text("Second").build()); - - Msg msg = - Msg.builder() - .name("Tool") - .role(MsgRole.TOOL) - .content( - List.of( - ToolResultBlock.builder() - .id("call_123") - .name("search") - .output((List) outputBlocks) - .build())) - .build(); - - List result = converter.convert(List.of(msg)); - - assertEquals(1, result.size()); - List blocks = result.get(0).content().asBlockParams(); - assertTrue(blocks.get(0).isToolResult()); - } - - @Test - void testConvertToolResultBlockNullOutput() { - // Builder without output() call will have null output, which becomes empty list - Msg msg = - Msg.builder() - .name("Tool") - .role(MsgRole.TOOL) - .content( - List.of( - ToolResultBlock.builder() - .id("call_123") - .name("search") - .build())) - .build(); - - List result = converter.convert(List.of(msg)); - - assertEquals(1, result.size()); - assertTrue(result.get(0).content().asBlockParams().get(0).isToolResult()); - } - - @Test - void testConvertMixedContentBlocks() { - Base64Source imageSource = - Base64Source.builder() - .data("ZmFrZSBpbWFnZSBjb250ZW50") - .mediaType("image/png") - .build(); - - Msg msg = - Msg.builder() - .name("User") - .role(MsgRole.USER) - .content( - List.of( - TextBlock.builder().text("Look at this:").build(), - ImageBlock.builder().source(imageSource).build(), - TextBlock.builder().text("What is it?").build())) - .build(); - - List result = converter.convert(List.of(msg)); - - assertEquals(1, result.size()); - List blocks = result.get(0).content().asBlockParams(); - assertEquals(3, blocks.size()); - assertTrue(blocks.get(0).isText()); - assertTrue(blocks.get(1).isImage()); - assertTrue(blocks.get(2).isText()); + assertEquals("tool_result", blocks.get(0).getType()); + assertEquals("call_123", blocks.get(0).getToolUseId()); + + // Check content - it's a List in the new implementation + // (because ToolResultBlock output is a List) + Object contentObj = blocks.get(0).getContent(); + assertNotNull(contentObj); + assertTrue(contentObj instanceof List, "Content should be a List"); + List contentList = (List) contentObj; + assertEquals(1, contentList.size()); + assertTrue(contentList.get(0) instanceof AnthropicContent); + AnthropicContent textContent = (AnthropicContent) contentList.get(0); + assertEquals("text", textContent.getType()); + assertEquals("Result text", textContent.getText()); } @Test @@ -388,45 +265,29 @@ void testConvertMessageWithToolResultAndRegularContent() { .build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg)); // Should split into two messages: regular content + tool result assertEquals(2, result.size()); // First message has regular content - assertEquals(MessageParam.Role.USER, result.get(0).role()); - List firstBlocks = result.get(0).content().asBlockParams(); + assertEquals("user", result.get(0).getRole()); + List firstBlocks = result.get(0).getContent(); assertEquals(1, firstBlocks.size()); - assertTrue(firstBlocks.get(0).isText()); + assertEquals("text", firstBlocks.get(0).getType()); // Second message has tool result - assertEquals(MessageParam.Role.USER, result.get(1).role()); - List secondBlocks = result.get(1).content().asBlockParams(); + assertEquals("user", result.get(1).getRole()); + List secondBlocks = result.get(1).getContent(); assertEquals(1, secondBlocks.size()); - assertTrue(secondBlocks.get(0).isToolResult()); - } - - @Test - void testConvertMultipleMessages() { - Msg msg1 = - Msg.builder() - .name("User") - .role(MsgRole.USER) - .content(List.of(TextBlock.builder().text("Hello").build())) - .build(); - - Msg msg2 = - Msg.builder() - .name("Assistant") - .role(MsgRole.ASSISTANT) - .content(List.of(TextBlock.builder().text("Hi").build())) - .build(); - - List result = converter.convert(List.of(msg1, msg2)); - - assertEquals(2, result.size()); - assertEquals(MessageParam.Role.USER, result.get(0).role()); - assertEquals(MessageParam.Role.ASSISTANT, result.get(1).role()); + assertEquals("tool_result", secondBlocks.get(0).getType()); + // Verify content structure for tool result (List of 1 TextBlock) + Object contentObj = secondBlocks.get(0).getContent(); + assertTrue(contentObj instanceof List); + List contentList = (List) contentObj; + assertEquals(1, contentList.size()); + AnthropicContent textContent = (AnthropicContent) contentList.get(0); + assertEquals("Result", textContent.getText()); } @Test @@ -443,23 +304,6 @@ void testExtractSystemMessagePresent() { assertEquals("System prompt", systemMessage); } - @Test - void testExtractSystemMessageMultipleTextBlocks() { - Msg msg = - Msg.builder() - .name("System") - .role(MsgRole.SYSTEM) - .content( - List.of( - TextBlock.builder().text("First").build(), - TextBlock.builder().text("Second").build())) - .build(); - - String systemMessage = converter.extractSystemMessage(List.of(msg)); - - assertEquals("First\nSecond", systemMessage); - } - @Test void testExtractSystemMessageNotFirst() { Msg userMsg = @@ -482,74 +326,25 @@ void testExtractSystemMessageNotFirst() { } @Test - void testExtractSystemMessageEmpty() { - String systemMessage = converter.extractSystemMessage(List.of()); - - assertNull(systemMessage); - } - - @Test - void testExtractSystemMessageNonSystemRole() { - Msg msg = + void testConvertMultipleMessages() { + Msg msg1 = Msg.builder() .name("User") .role(MsgRole.USER) .content(List.of(TextBlock.builder().text("Hello").build())) .build(); - String systemMessage = converter.extractSystemMessage(List.of(msg)); - - assertNull(systemMessage); - } - - @Test - void testConvertEmptyMessage() { - Msg msg = Msg.builder().name("User").role(MsgRole.USER).content(List.of()).build(); - - List result = converter.convert(List.of(msg)); - - // Empty content should return empty result or be filtered - assertTrue(result.isEmpty() || result.get(0).content().asBlockParams().isEmpty()); - } - - @Test - void testConvertToolUseBlockWithNullInput() { - Msg msg = + Msg msg2 = Msg.builder() .name("Assistant") .role(MsgRole.ASSISTANT) - .content( - List.of( - ToolUseBlock.builder() - .id("call_123") - .name("search") - .input(null) - .build())) - .build(); - - List result = converter.convert(List.of(msg)); - - assertEquals(1, result.size()); - List blocks = result.get(0).content().asBlockParams(); - assertTrue(blocks.get(0).isToolUse()); - assertEquals("call_123", blocks.get(0).asToolUse().id()); - assertEquals("search", blocks.get(0).asToolUse().name()); - // Note: null input is converted to empty map during conversion - } - - @Test - void testConvertToolRoleMessage() { - Msg msg = - Msg.builder() - .name("Tool") - .role(MsgRole.TOOL) - .content(List.of(TextBlock.builder().text("Result").build())) + .content(List.of(TextBlock.builder().text("Hi").build())) .build(); - List result = converter.convert(List.of(msg)); + List result = converter.convert(List.of(msg1, msg2)); - assertEquals(1, result.size()); - // TOOL role should be converted to USER - assertEquals(MessageParam.Role.USER, result.get(0).role()); + assertEquals(2, result.size()); + assertEquals("user", result.get(0).getRole()); + assertEquals("assistant", result.get(1).getRole()); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMultiAgentFormatterGroundTruthTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMultiAgentFormatterGroundTruthTest.java index dee3555a8..0deaeb7fd 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMultiAgentFormatterGroundTruthTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicMultiAgentFormatterGroundTruthTest.java @@ -16,10 +16,13 @@ package io.agentscope.core.formatter.anthropic; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -import com.anthropic.core.ObjectMappers; -import com.anthropic.models.messages.MessageParam; import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; @@ -36,7 +39,8 @@ import org.junit.jupiter.api.Test; /** - * Ground truth tests for AnthropicMultiAgentFormatter - compares with Python implementation. + * Ground truth tests for AnthropicMultiAgentFormatter - compares with expected + * JSON structure. */ class AnthropicMultiAgentFormatterGroundTruthTest { @@ -48,13 +52,19 @@ class AnthropicMultiAgentFormatterGroundTruthTest { private List msgsTools; private List msgsConversation2; private List msgsTools2; + private AnthropicMediaConverter mediaConverter; @BeforeEach - void setUp() { - formatter = new AnthropicMultiAgentFormatter(); + void setUp() throws Exception { + mediaConverter = mock(AnthropicMediaConverter.class); + formatter = new AnthropicMultiAgentFormatter(mediaConverter); jsonCodec = JsonUtils.getJsonCodec(); imageUrl = "https://www.example.com/image.png"; + // Mock media converter + when(mediaConverter.convertImageBlock(any(ImageBlock.class))) + .thenReturn(new AnthropicContent.ImageSource("image/png", "base64data")); + // System message msgsSystem = List.of( @@ -213,11 +223,11 @@ void testMultiAgentFormatterFullHistory() throws Exception { allMsgs.addAll(msgsConversation2); allMsgs.addAll(msgsTools2); - List result = formatter.format(allMsgs); - String resultJson = ObjectMappers.jsonMapper().writeValueAsString(result); + List result = formatter.format(allMsgs); + String resultJson = jsonCodec.toJson(result); JsonNode resultNode = jsonCodec.fromJson(resultJson, JsonNode.class); - // Ground truth from Python implementation + // Ground truth updated to match DTO structure and Mock behavior String groundTruthJson = """ [ @@ -240,8 +250,9 @@ void testMultiAgentFormatterFullHistory() throws Exception { { "type": "image", "source": { - "type": "url", - "url": "https://www.example.com/image.png" + "type": "base64", + "media_type": "image/png", + "data": "base64data" } }, { @@ -341,8 +352,8 @@ void testMultiAgentFormatterWithoutSystemMessage() throws Exception { allMsgs.add(msgsTools.get(0)); // assistant with tool_use allMsgs.add(msgsTools.get(1)); // system with tool_result - List result = formatter.format(allMsgs); - String resultJson = ObjectMappers.jsonMapper().writeValueAsString(result); + List result = formatter.format(allMsgs); + String resultJson = jsonCodec.toJson(result); JsonNode resultNode = jsonCodec.fromJson(resultJson, JsonNode.class); String groundTruthJson = @@ -358,8 +369,9 @@ void testMultiAgentFormatterWithoutSystemMessage() throws Exception { { "type": "image", "source": { - "type": "url", - "url": "https://www.example.com/image.png" + "type": "base64", + "media_type": "image/png", + "data": "base64data" } }, { @@ -405,8 +417,8 @@ void testMultiAgentFormatterWithoutSystemMessage() throws Exception { @Test void testMultiAgentFormatterOnlySystemMessage() throws Exception { - List result = formatter.format(msgsSystem); - String resultJson = ObjectMappers.jsonMapper().writeValueAsString(result); + List result = formatter.format(msgsSystem); + String resultJson = jsonCodec.toJson(result); JsonNode resultNode = jsonCodec.fromJson(resultJson, JsonNode.class); String groundTruthJson = @@ -430,8 +442,8 @@ void testMultiAgentFormatterOnlySystemMessage() throws Exception { @Test void testMultiAgentFormatterOnlyConversation() throws Exception { - List result = formatter.format(msgsConversation); - String resultJson = ObjectMappers.jsonMapper().writeValueAsString(result); + List result = formatter.format(msgsConversation); + String resultJson = jsonCodec.toJson(result); JsonNode resultNode = jsonCodec.fromJson(resultJson, JsonNode.class); String groundTruthJson = @@ -447,8 +459,9 @@ void testMultiAgentFormatterOnlyConversation() throws Exception { { "type": "image", "source": { - "type": "url", - "url": "https://www.example.com/image.png" + "type": "base64", + "media_type": "image/png", + "data": "base64data" } }, { diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParserTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParserTest.java index cb311ba88..ff803eeca 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParserTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicResponseParserTest.java @@ -16,27 +16,19 @@ package io.agentscope.core.formatter.anthropic; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.anthropic.models.messages.ContentBlock; -import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.RawMessageStartEvent; -import com.anthropic.models.messages.RawMessageStreamEvent; -import com.anthropic.models.messages.TextBlock; -import com.anthropic.models.messages.ThinkingBlock; -import com.anthropic.models.messages.ToolUseBlock; -import com.anthropic.models.messages.Usage; + +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicResponse; +import io.agentscope.core.formatter.anthropic.dto.AnthropicStreamEvent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicUsage; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.ChatUsage; -import java.lang.reflect.Method; import java.time.Instant; import java.util.List; -import java.util.Optional; +import java.util.Map; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; @@ -44,51 +36,31 @@ /** Unit tests for AnthropicResponseParser. */ class AnthropicResponseParserTest extends AnthropicFormatterTestBase { - /** - * Use reflection to call private parseStreamEvent method for unit testing individual event - * types. - */ - private ChatResponse invokeParseStreamEvent(RawMessageStreamEvent event, Instant startTime) - throws Exception { - Method method = - AnthropicResponseParser.class.getDeclaredMethod( - "parseStreamEvent", RawMessageStreamEvent.class, Instant.class); - method.setAccessible(true); - return (ChatResponse) method.invoke(null, event, startTime); - } - @Test void testParseMessageWithTextBlock() { - // Create mock Message with text content - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - TextBlock textBlock = mock(TextBlock.class); - - when(message.id()).thenReturn("msg_123"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(100L); - when(usage.outputTokens()).thenReturn(50L); - - when(contentBlock.text()).thenReturn(Optional.of(textBlock)); - when(contentBlock.toolUse()).thenReturn(Optional.empty()); - when(contentBlock.thinking()).thenReturn(Optional.empty()); - when(textBlock.text()).thenReturn("Hello, world!"); + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_123"); + response.setContent(List.of(AnthropicContent.text("Hello, world!"))); - Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(100); + usage.setOutputTokens(50); + response.setUsage(usage); - assertNotNull(response); - assertEquals("msg_123", response.getId()); - assertEquals(1, response.getContent().size()); - assertTrue(response.getContent().get(0) instanceof io.agentscope.core.message.TextBlock); + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); - io.agentscope.core.message.TextBlock parsedText = - (io.agentscope.core.message.TextBlock) response.getContent().get(0); - assertEquals("Hello, world!", parsedText.getText()); + assertNotNull(chatResponse); + assertEquals("msg_123", chatResponse.getId()); + assertEquals(1, chatResponse.getContent().size()); + assertTrue( + chatResponse.getContent().get(0) instanceof io.agentscope.core.message.TextBlock); + assertEquals( + "Hello, world!", + ((io.agentscope.core.message.TextBlock) chatResponse.getContent().get(0)) + .getText()); - ChatUsage responseUsage = response.getUsage(); + ChatUsage responseUsage = chatResponse.getUsage(); assertNotNull(responseUsage); assertEquals(100, responseUsage.getInputTokens()); assertEquals(50, responseUsage.getOutputTokens()); @@ -96,285 +68,457 @@ void testParseMessageWithTextBlock() { @Test void testParseMessageWithToolUseBlock() { - // Create mock Message with tool use content - // Note: We use null input to avoid Kotlin reflection issues with JsonValue mocking - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - ToolUseBlock toolUseBlock = mock(ToolUseBlock.class); - - when(message.id()).thenReturn("msg_456"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(200L); - when(usage.outputTokens()).thenReturn(100L); - - when(contentBlock.text()).thenReturn(Optional.empty()); - when(contentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); - when(contentBlock.thinking()).thenReturn(Optional.empty()); - - when(toolUseBlock.id()).thenReturn("tool_call_123"); - when(toolUseBlock.name()).thenReturn("search"); - when(toolUseBlock._input()).thenReturn(null); // Avoid Kotlin reflection issues + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_456"); + + AnthropicContent toolContent = new AnthropicContent(); + toolContent.setType("tool_use"); + toolContent.setId("tool_call_123"); + toolContent.setName("search"); + toolContent.setInput(Map.of("query", "test")); + response.setContent(List.of(toolContent)); + + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(200); + usage.setOutputTokens(100); + response.setUsage(usage); Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); - - assertNotNull(response); - assertEquals("msg_456", response.getId()); - assertEquals(1, response.getContent().size()); - assertTrue(response.getContent().get(0) instanceof io.agentscope.core.message.ToolUseBlock); - - io.agentscope.core.message.ToolUseBlock parsedToolUse = - (io.agentscope.core.message.ToolUseBlock) response.getContent().get(0); - assertEquals("tool_call_123", parsedToolUse.getId()); - assertEquals("search", parsedToolUse.getName()); - assertNotNull(parsedToolUse.getInput()); - // Null input should result in empty map - assertTrue(parsedToolUse.getInput().isEmpty()); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); + + assertNotNull(chatResponse); + assertEquals("msg_456", chatResponse.getId()); + assertEquals(1, chatResponse.getContent().size()); + assertTrue( + chatResponse.getContent().get(0) + instanceof io.agentscope.core.message.ToolUseBlock); + + io.agentscope.core.message.ToolUseBlock toolUse = + (io.agentscope.core.message.ToolUseBlock) chatResponse.getContent().get(0); + assertEquals("tool_call_123", toolUse.getId()); + assertEquals("search", toolUse.getName()); + assertEquals("test", toolUse.getInput().get("query")); } @Test void testParseMessageWithThinkingBlock() { - // Create mock Message with thinking content - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - ThinkingBlock thinkingBlock = mock(ThinkingBlock.class); - - when(message.id()).thenReturn("msg_789"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(150L); - when(usage.outputTokens()).thenReturn(75L); - - when(contentBlock.text()).thenReturn(Optional.empty()); - when(contentBlock.toolUse()).thenReturn(Optional.empty()); - when(contentBlock.thinking()).thenReturn(Optional.of(thinkingBlock)); - when(thinkingBlock.thinking()).thenReturn("Let me think about this..."); + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_789"); + response.setContent(List.of(AnthropicContent.thinking("Let me think..."))); + + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(150); + usage.setOutputTokens(75); + response.setUsage(usage); Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); - assertNotNull(response); - assertEquals("msg_789", response.getId()); - assertEquals(1, response.getContent().size()); + assertNotNull(chatResponse); + assertEquals(1, chatResponse.getContent().size()); assertTrue( - response.getContent().get(0) instanceof io.agentscope.core.message.ThinkingBlock); + chatResponse.getContent().get(0) + instanceof io.agentscope.core.message.ThinkingBlock); - io.agentscope.core.message.ThinkingBlock parsedThinking = - (io.agentscope.core.message.ThinkingBlock) response.getContent().get(0); - assertEquals("Let me think about this...", parsedThinking.getThinking()); + io.agentscope.core.message.ThinkingBlock thinking = + (io.agentscope.core.message.ThinkingBlock) chatResponse.getContent().get(0); + assertEquals("Let me think...", thinking.getThinking()); } @Test void testParseMessageWithMixedContent() { - // Create mock Message with multiple content blocks - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - - ContentBlock textContentBlock = mock(ContentBlock.class); - TextBlock textBlock = mock(TextBlock.class); - - ContentBlock toolContentBlock = mock(ContentBlock.class); - ToolUseBlock toolUseBlock = mock(ToolUseBlock.class); - - when(message.id()).thenReturn("msg_mixed"); - when(message.content()).thenReturn(List.of(textContentBlock, toolContentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(300L); - when(usage.outputTokens()).thenReturn(150L); - - // Text block - when(textContentBlock.text()).thenReturn(Optional.of(textBlock)); - when(textContentBlock.toolUse()).thenReturn(Optional.empty()); - when(textContentBlock.thinking()).thenReturn(Optional.empty()); - when(textBlock.text()).thenReturn("Let me search for that."); - - // Tool use block - use null input to avoid Kotlin reflection issues - when(toolContentBlock.text()).thenReturn(Optional.empty()); - when(toolContentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); - when(toolContentBlock.thinking()).thenReturn(Optional.empty()); - when(toolUseBlock.id()).thenReturn("tool_xyz"); - when(toolUseBlock.name()).thenReturn("web_search"); - when(toolUseBlock._input()).thenReturn(null); // Avoid Kotlin reflection issues + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_mixed"); - Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + AnthropicContent toolContent = new AnthropicContent(); + toolContent.setType("tool_use"); + toolContent.setId("tool_xyz"); + toolContent.setName("web_search"); + toolContent.setInput(Map.of()); - assertNotNull(response); - assertEquals("msg_mixed", response.getId()); - assertEquals(2, response.getContent().size()); + response.setContent(List.of(AnthropicContent.text("Let me search for that."), toolContent)); + + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(300); + usage.setOutputTokens(150); + response.setUsage(usage); + + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); - assertTrue(response.getContent().get(0) instanceof io.agentscope.core.message.TextBlock); - assertTrue(response.getContent().get(1) instanceof io.agentscope.core.message.ToolUseBlock); + assertNotNull(chatResponse); + assertEquals(2, chatResponse.getContent().size()); + assertTrue( + chatResponse.getContent().get(0) instanceof io.agentscope.core.message.TextBlock); + assertTrue( + chatResponse.getContent().get(1) + instanceof io.agentscope.core.message.ToolUseBlock); } @Test - void testParseMessageWithEmptyContent() { - // Create mock Message with no content - Message message = mock(Message.class); - Usage usage = mock(Usage.class); + void testParseStreamEventsMessageStart() { + AnthropicStreamEvent event = new AnthropicStreamEvent(); + event.setType("message_start"); - when(message.id()).thenReturn("msg_empty"); - when(message.content()).thenReturn(List.of()); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(50L); - when(usage.outputTokens()).thenReturn(0L); + // message_start event has a specialized 'message' field which is + // AnthropicResponse + AnthropicResponse message = new AnthropicResponse(); + message.setId("msg_stream_123"); + + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(10); + usage.setOutputTokens(0); + message.setUsage(usage); // Set usage on the message structure + + event.setMessage(message); Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); + Flux responseFlux = + AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); - assertNotNull(response); - assertEquals("msg_empty", response.getId()); - assertTrue(response.getContent().isEmpty()); + // Current implementation filters out empty content responses, so message_start + // is verified to complete empty + StepVerifier.create(responseFlux).verifyComplete(); } @Test - void testParseMessageWithNullToolInput() { - // Create mock Message with null tool input - Message message = mock(Message.class); - Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); - ToolUseBlock toolUseBlock = mock(ToolUseBlock.class); - - when(message.id()).thenReturn("msg_null_input"); - when(message.content()).thenReturn(List.of(contentBlock)); - when(message.usage()).thenReturn(usage); - when(usage.inputTokens()).thenReturn(100L); - when(usage.outputTokens()).thenReturn(50L); - - when(contentBlock.text()).thenReturn(Optional.empty()); - when(contentBlock.toolUse()).thenReturn(Optional.of(toolUseBlock)); - when(contentBlock.thinking()).thenReturn(Optional.empty()); - - when(toolUseBlock.id()).thenReturn("tool_null"); - when(toolUseBlock.name()).thenReturn("test_tool"); - when(toolUseBlock._input()).thenReturn(null); + void testParseStreamEventsTextDelta() { + AnthropicStreamEvent event = new AnthropicStreamEvent(); + event.setType("content_block_delta"); + event.setIndex(0); + + AnthropicStreamEvent.Delta delta = new AnthropicStreamEvent.Delta(); + delta.setType("text_delta"); + delta.setText("Hello stream"); + event.setDelta(delta); Instant startTime = Instant.now(); - ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); - - assertNotNull(response); - assertEquals(1, response.getContent().size()); - - io.agentscope.core.message.ToolUseBlock parsedToolUse = - (io.agentscope.core.message.ToolUseBlock) response.getContent().get(0); - assertEquals("tool_null", parsedToolUse.getId()); - assertEquals("test_tool", parsedToolUse.getName()); - // Null input should result in empty map - assertNotNull(parsedToolUse.getInput()); - assertTrue(parsedToolUse.getInput().isEmpty()); + Flux responseFlux = + AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); + + StepVerifier.create(responseFlux) + .assertNext( + res -> { + assertEquals(1, res.getContent().size()); + assertTrue( + res.getContent().get(0) + instanceof io.agentscope.core.message.TextBlock); + assertEquals( + "Hello stream", + ((io.agentscope.core.message.TextBlock) res.getContent().get(0)) + .getText()); + }) + .verifyComplete(); } @Test - void testParseStreamEventsMessageStart() { - // Create mock MessageStart event - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); - RawMessageStartEvent messageStartEvent = mock(RawMessageStartEvent.class); - Message message = mock(Message.class); - - when(event.isMessageStart()).thenReturn(true); - when(event.asMessageStart()).thenReturn(messageStartEvent); - when(messageStartEvent.message()).thenReturn(message); - when(message.id()).thenReturn("msg_stream_123"); + void testParseStreamEventsUnknownType() { + AnthropicStreamEvent event = new AnthropicStreamEvent(); + event.setType("unknown_event"); Instant startTime = Instant.now(); Flux responseFlux = AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); - // MessageStart events should be filtered out (empty content) StepVerifier.create(responseFlux).verifyComplete(); } @Test - void testParseStreamEventMessageStart() throws Exception { - // Test MessageStart event - should set message ID but have empty content - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); - RawMessageStartEvent messageStart = mock(RawMessageStartEvent.class); - Message message = mock(Message.class); + void testParseStreamEventsErrorHandling() { + // Create a Flux that emits an error + Flux errorFlux = Flux.error(new RuntimeException("Stream error")); + + Instant startTime = Instant.now(); + + // parseStreamEvents should propagate errors + StepVerifier.create(AnthropicResponseParser.parseStreamEvents(errorFlux, startTime)) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testParseMessageWithNullContent() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_null_content"); + response.setContent(null); + + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(50); + usage.setOutputTokens(25); + response.setUsage(usage); + + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); + + assertNotNull(chatResponse); + assertTrue(chatResponse.getContent().isEmpty()); + assertNotNull(chatResponse.getUsage()); + } + + @Test + void testParseMessageWithEmptyContent() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_empty_content"); + response.setContent(List.of()); - when(event.isMessageStart()).thenReturn(true); - when(event.asMessageStart()).thenReturn(messageStart); - when(messageStart.message()).thenReturn(message); - when(message.id()).thenReturn("msg_stream_123"); + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(30); + usage.setOutputTokens(15); + response.setUsage(usage); - when(event.isContentBlockDelta()).thenReturn(false); - when(event.isContentBlockStart()).thenReturn(false); - when(event.isMessageDelta()).thenReturn(false); + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); + + assertNotNull(chatResponse); + assertTrue(chatResponse.getContent().isEmpty()); + assertNotNull(chatResponse.getUsage()); + } + + @Test + void testParseMessageWithNullUsage() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_no_usage"); + response.setContent(List.of(AnthropicContent.text("Hello"))); + response.setUsage(null); Instant startTime = Instant.now(); - ChatResponse response = invokeParseStreamEvent(event, startTime); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); - assertNotNull(response); - assertEquals("msg_stream_123", response.getId()); - assertTrue(response.getContent().isEmpty()); // MessageStart has no content + assertNotNull(chatResponse); + assertEquals(1, chatResponse.getContent().size()); + // Usage should be null when not provided + assertNull(chatResponse.getUsage()); } @Test - void testParseStreamEventUnknownType() throws Exception { - // Test unknown event type - should return empty response - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + void testParseMessageWithNullTokensInUsage() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_null_tokens"); + response.setContent(List.of(AnthropicContent.text("Response"))); - when(event.isMessageStart()).thenReturn(false); - when(event.isContentBlockDelta()).thenReturn(false); - when(event.isContentBlockStart()).thenReturn(false); - when(event.isMessageDelta()).thenReturn(false); + AnthropicUsage usage = new AnthropicUsage(); + usage.setInputTokens(null); + usage.setOutputTokens(null); + response.setUsage(usage); Instant startTime = Instant.now(); - ChatResponse response = invokeParseStreamEvent(event, startTime); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); - assertNotNull(response); - assertNotNull(response.getId()); // Builder auto-generates UUID when id is null - assertFalse(response.getId().isEmpty()); - assertTrue(response.getContent().isEmpty()); - assertNull(response.getUsage()); + assertNotNull(chatResponse); + assertNotNull(chatResponse.getUsage()); + assertEquals(0, chatResponse.getUsage().getInputTokens()); + assertEquals(0, chatResponse.getUsage().getOutputTokens()); } @Test - void testParseStreamEventsFiltersEmptyContent() { - // Test that parseStreamEvents filters out responses with empty content - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + void testParseMessageWithNullTextBlock() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_null_text"); - when(event.isMessageStart()).thenReturn(false); - when(event.isContentBlockDelta()).thenReturn(false); - when(event.isContentBlockStart()).thenReturn(false); - when(event.isMessageDelta()).thenReturn(false); + AnthropicContent content = new AnthropicContent(); + content.setType("text"); + content.setText(null); + response.setContent(List.of(content)); + + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); + + assertNotNull(chatResponse); + assertEquals("msg_null_text", chatResponse.getId()); + assertTrue(chatResponse.getContent().isEmpty()); + assertNull(chatResponse.getUsage()); + } + + @Test + void testParseMessageWithUnknownContentType() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_unknown_type"); + + AnthropicContent content = new AnthropicContent(); + content.setType("unknown_type"); + response.setContent(List.of(content)); + + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); + + assertNotNull(chatResponse); + // Unknown content types should be skipped + assertTrue(chatResponse.getContent().isEmpty()); + } + + @Test + void testParseStreamEventsContentBlockStart() { + AnthropicStreamEvent event = new AnthropicStreamEvent(); + event.setType("content_block_start"); + event.setIndex(0); + + AnthropicContent toolContent = new AnthropicContent(); + toolContent.setType("tool_use"); + toolContent.setId("tool_123"); + toolContent.setName("calculator"); + event.setContentBlock(toolContent); Instant startTime = Instant.now(); Flux responseFlux = AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); - // Empty content responses should be filtered out - StepVerifier.create(responseFlux).verifyComplete(); + StepVerifier.create(responseFlux) + .assertNext( + res -> { + assertEquals(1, res.getContent().size()); + assertTrue( + res.getContent().get(0) + instanceof io.agentscope.core.message.ToolUseBlock); + io.agentscope.core.message.ToolUseBlock toolUse = + (io.agentscope.core.message.ToolUseBlock) + res.getContent().get(0); + assertEquals("tool_123", toolUse.getId()); + assertEquals("calculator", toolUse.getName()); + }) + .verifyComplete(); + } + + @Test + void testParseStreamEventsContentBlockDeltaWithPartialJson() { + AnthropicStreamEvent event = new AnthropicStreamEvent(); + event.setType("content_block_delta"); + event.setIndex(0); + + AnthropicStreamEvent.Delta delta = new AnthropicStreamEvent.Delta(); + delta.setType("input_json_delta"); + delta.setPartialJson("{\"query\":"); + event.setDelta(delta); + + Instant startTime = Instant.now(); + Flux responseFlux = + AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); + + StepVerifier.create(responseFlux) + .assertNext( + res -> { + assertEquals(1, res.getContent().size()); + assertTrue( + res.getContent().get(0) + instanceof io.agentscope.core.message.ToolUseBlock); + io.agentscope.core.message.ToolUseBlock toolUse = + (io.agentscope.core.message.ToolUseBlock) + res.getContent().get(0); + assertEquals("__fragment__", toolUse.getName()); + assertEquals("{\"query\":", toolUse.getContent()); + }) + .verifyComplete(); } @Test - void testParseStreamEventsHandlesExceptions() { - // Test that exceptions in parsing are caught and logged - RawMessageStreamEvent event = mock(RawMessageStreamEvent.class); + void testParseStreamEventsMessageDelta() { + // message_delta events with only usage (no content) are filtered out + // The usage info would typically be attached to the last content block in a real stream + AnthropicStreamEvent event = new AnthropicStreamEvent(); + event.setType("message_delta"); + + AnthropicStreamEvent.Delta delta = new AnthropicStreamEvent.Delta(); + event.setDelta(delta); - // Make the event throw an exception - when(event.isMessageStart()).thenThrow(new RuntimeException("Test exception")); + io.agentscope.core.formatter.anthropic.dto.AnthropicUsage usage = + new io.agentscope.core.formatter.anthropic.dto.AnthropicUsage(); + usage.setOutputTokens(150); + event.setUsage(usage); Instant startTime = Instant.now(); Flux responseFlux = AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); - // Exception should be caught and result in empty flux + // message_delta with only usage (no content) completes without emitting StepVerifier.create(responseFlux).verifyComplete(); } @Test - void testParseStreamEventsErrorHandling() { - // Create a Flux that emits an error - Flux errorFlux = Flux.error(new RuntimeException("Stream error")); + void testParseStreamEventsWithNullType() { + AnthropicStreamEvent event = new AnthropicStreamEvent(); + event.setType(null); Instant startTime = Instant.now(); + Flux responseFlux = + AnthropicResponseParser.parseStreamEvents(Flux.just(event), startTime); - // parseStreamEvents should propagate errors - StepVerifier.create(AnthropicResponseParser.parseStreamEvents(errorFlux, startTime)) - .expectError(RuntimeException.class) - .verify(); + // Should handle null type gracefully and complete + StepVerifier.create(responseFlux).verifyComplete(); + } + + @Test + void testParseMessageWithMultipleToolUseBlocks() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_multiple_tools"); + + AnthropicContent tool1 = new AnthropicContent(); + tool1.setType("tool_use"); + tool1.setId("tool_1"); + tool1.setName("search"); + tool1.setInput(Map.of("query", "test")); + + AnthropicContent tool2 = new AnthropicContent(); + tool2.setType("tool_use"); + tool2.setId("tool_2"); + tool2.setName("calculate"); + tool2.setInput(Map.of("expression", "2+2")); + + response.setContent(List.of(tool1, tool2)); + + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); + + assertNotNull(chatResponse); + assertEquals(2, chatResponse.getContent().size()); + + io.agentscope.core.message.ToolUseBlock firstTool = + (io.agentscope.core.message.ToolUseBlock) chatResponse.getContent().get(0); + assertEquals("tool_1", firstTool.getId()); + assertEquals("search", firstTool.getName()); + + io.agentscope.core.message.ToolUseBlock secondTool = + (io.agentscope.core.message.ToolUseBlock) chatResponse.getContent().get(1); + assertEquals("tool_2", secondTool.getId()); + assertEquals("calculate", secondTool.getName()); + } + + @Test + void testParseMessageWithToolUseNullInput() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_null_input"); + + AnthropicContent toolContent = new AnthropicContent(); + toolContent.setType("tool_use"); + toolContent.setId("tool_1"); + toolContent.setName("search"); + toolContent.setInput(null); + response.setContent(List.of(toolContent)); + + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); + + assertNotNull(chatResponse); + assertEquals(1, chatResponse.getContent().size()); + io.agentscope.core.message.ToolUseBlock toolUse = + (io.agentscope.core.message.ToolUseBlock) chatResponse.getContent().get(0); + assertNotNull(toolUse.getInput()); + assertTrue(toolUse.getInput().isEmpty()); + } + + @Test + void testParseMessageWithThinkingNullText() { + AnthropicResponse response = new AnthropicResponse(); + response.setId("msg_null_thinking"); + + AnthropicContent thinkingContent = new AnthropicContent(); + thinkingContent.setType("thinking"); + thinkingContent.setThinking(null); + response.setContent(List.of(thinkingContent)); + + Instant startTime = Instant.now(); + ChatResponse chatResponse = AnthropicResponseParser.parseMessage(response, startTime); + + assertNotNull(chatResponse); + // Null thinking should be skipped + assertTrue(chatResponse.getContent().isEmpty()); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicToolsHelperTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicToolsHelperTest.java index 3d856124f..5bbdce37b 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicToolsHelperTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/AnthropicToolsHelperTest.java @@ -17,12 +17,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNull; -import com.anthropic.models.messages.MessageCreateParams; -import com.anthropic.models.messages.MessageParam; -import com.anthropic.models.messages.Tool; -import com.anthropic.models.messages.ToolUnion; +import io.agentscope.core.formatter.anthropic.dto.AnthropicRequest; +import io.agentscope.core.formatter.anthropic.dto.AnthropicTool; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.ToolChoice; import io.agentscope.core.model.ToolSchema; @@ -34,21 +32,11 @@ /** Unit tests for AnthropicToolsHelper. */ class AnthropicToolsHelperTest { - /** Helper method to create a builder with required dummy message. */ - private MessageCreateParams.Builder createBuilder() { - return MessageCreateParams.builder() - .model("claude-sonnet-4-5-20250929") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content("test") - .build()); - } - @Test void testApplyToolsWithSimpleSchema() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); + request.setModel("claude-sonnet-4-5-20250929"); + request.setMaxTokens(1024); Map parameters = new HashMap<>(); parameters.put("type", "object"); @@ -63,23 +51,21 @@ void testApplyToolsWithSimpleSchema() { .build(); GenerateOptions options = GenerateOptions.builder().build(); - AnthropicToolsHelper.applyTools(builder, List.of(toolSchema), options); + AnthropicToolsHelper.applyTools(request, List.of(toolSchema), options); - MessageCreateParams params = builder.build(); - assertTrue(params.tools().isPresent()); - List tools = params.tools().get(); + List tools = request.getTools(); + assertNotNull(tools); assertEquals(1, tools.size()); - assertTrue(tools.get(0).isTool()); - Tool tool = tools.get(0).asTool(); - assertEquals("search", tool.name()); - assertEquals("Search for information", tool.description().get()); - // Note: inputSchema validation is handled by Anthropic SDK during API calls + AnthropicTool tool = tools.get(0); + assertEquals("search", tool.getName()); + assertEquals("Search for information", tool.getDescription()); + assertNotNull(tool.getInputSchema()); } @Test void testApplyToolsWithMultipleSchemas() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); ToolSchema schema1 = ToolSchema.builder() @@ -96,32 +82,30 @@ void testApplyToolsWithMultipleSchemas() { .build(); GenerateOptions options = GenerateOptions.builder().build(); - AnthropicToolsHelper.applyTools(builder, List.of(schema1, schema2), options); + AnthropicToolsHelper.applyTools(request, List.of(schema1, schema2), options); - MessageCreateParams params = builder.build(); - assertTrue(params.tools().isPresent()); - assertEquals(2, params.tools().get().size()); + List tools = request.getTools(); + assertNotNull(tools); + assertEquals(2, tools.size()); } @Test void testApplyToolsWithNullOrEmptyList() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); // Null list - AnthropicToolsHelper.applyTools(builder, null, null); - MessageCreateParams params1 = builder.build(); - assertTrue(params1.tools().isEmpty()); + AnthropicToolsHelper.applyTools(request, null, null); + assertNull(request.getTools()); // Empty list - builder = createBuilder(); - AnthropicToolsHelper.applyTools(builder, List.of(), null); - MessageCreateParams params2 = builder.build(); - assertTrue(params2.tools().isEmpty()); + request = new AnthropicRequest(); + AnthropicToolsHelper.applyTools(request, List.of(), null); + assertNull(request.getTools()); } @Test void testApplyToolChoiceAuto() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); ToolSchema schema = ToolSchema.builder() @@ -132,16 +116,17 @@ void testApplyToolChoiceAuto() { GenerateOptions options = GenerateOptions.builder().toolChoice(new ToolChoice.Auto()).build(); - AnthropicToolsHelper.applyTools(builder, List.of(schema), options); + AnthropicToolsHelper.applyTools(request, List.of(schema), options); - MessageCreateParams params = builder.build(); - assertTrue(params.toolChoice().isPresent()); - assertTrue(params.toolChoice().get().isAuto()); + @SuppressWarnings("unchecked") + Map toolChoice = (Map) request.getToolChoice(); + assertNotNull(toolChoice); + assertEquals("auto", toolChoice.get("type")); } @Test void testApplyToolChoiceNone() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); ToolSchema schema = ToolSchema.builder() @@ -152,17 +137,19 @@ void testApplyToolChoiceNone() { GenerateOptions options = GenerateOptions.builder().toolChoice(new ToolChoice.None()).build(); - AnthropicToolsHelper.applyTools(builder, List.of(schema), options); - - MessageCreateParams params = builder.build(); - assertTrue(params.toolChoice().isPresent()); - // None maps to "any" in Anthropic - assertTrue(params.toolChoice().get().isAny()); + AnthropicToolsHelper.applyTools(request, List.of(schema), options); + + @SuppressWarnings("unchecked") + Map toolChoice = (Map) request.getToolChoice(); + assertNotNull(toolChoice); + // None maps to "any" in Anthropic implementation provided in + // AnthropicToolsHelper.java + assertEquals("any", toolChoice.get("type")); } @Test void testApplyToolChoiceRequired() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); ToolSchema schema = ToolSchema.builder() @@ -173,17 +160,19 @@ void testApplyToolChoiceRequired() { GenerateOptions options = GenerateOptions.builder().toolChoice(new ToolChoice.Required()).build(); - AnthropicToolsHelper.applyTools(builder, List.of(schema), options); - - MessageCreateParams params = builder.build(); - assertTrue(params.toolChoice().isPresent()); - // Required maps to "any" in Anthropic - assertTrue(params.toolChoice().get().isAny()); + AnthropicToolsHelper.applyTools(request, List.of(schema), options); + + @SuppressWarnings("unchecked") + Map toolChoice = (Map) request.getToolChoice(); + assertNotNull(toolChoice); + // Required maps to "any" in Anthropic implementation provided in + // AnthropicToolsHelper.java + assertEquals("any", toolChoice.get("type")); } @Test void testApplyToolChoiceSpecific() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); ToolSchema schema = ToolSchema.builder() @@ -194,104 +183,92 @@ void testApplyToolChoiceSpecific() { GenerateOptions options = GenerateOptions.builder().toolChoice(new ToolChoice.Specific("search")).build(); - AnthropicToolsHelper.applyTools(builder, List.of(schema), options); + AnthropicToolsHelper.applyTools(request, List.of(schema), options); - MessageCreateParams params = builder.build(); - assertTrue(params.toolChoice().isPresent()); - assertTrue(params.toolChoice().get().isTool()); - assertEquals("search", params.toolChoice().get().asTool().name()); + @SuppressWarnings("unchecked") + Map toolChoice = (Map) request.getToolChoice(); + assertNotNull(toolChoice); + assertEquals("tool", toolChoice.get("type")); + assertEquals("search", toolChoice.get("name")); } @Test void testApplyOptionsWithTemperature() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); GenerateOptions options = GenerateOptions.builder().temperature(0.7).build(); - AnthropicToolsHelper.applyOptions(builder, options, null); + AnthropicToolsHelper.applyOptions(request, options, null); - MessageCreateParams params = builder.build(); - assertTrue(params.temperature().isPresent()); - assertEquals(0.7, params.temperature().get(), 0.001); + assertEquals(0.7, request.getTemperature(), 0.001); } @Test void testApplyOptionsWithTopP() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); GenerateOptions options = GenerateOptions.builder().topP(0.9).build(); - AnthropicToolsHelper.applyOptions(builder, options, null); + AnthropicToolsHelper.applyOptions(request, options, null); - MessageCreateParams params = builder.build(); - assertTrue(params.topP().isPresent()); - assertEquals(0.9, params.topP().get(), 0.001); + assertEquals(0.9, request.getTopP(), 0.001); } @Test void testApplyOptionsWithMaxTokens() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); GenerateOptions options = GenerateOptions.builder().maxTokens(2048).build(); - AnthropicToolsHelper.applyOptions(builder, options, null); + AnthropicToolsHelper.applyOptions(request, options, null); - MessageCreateParams params = builder.build(); - assertEquals(2048, params.maxTokens()); + assertEquals(2048, request.getMaxTokens()); } @Test void testApplyOptionsWithAllParameters() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); GenerateOptions options = GenerateOptions.builder().temperature(0.8).topP(0.95).maxTokens(3000).build(); - AnthropicToolsHelper.applyOptions(builder, options, null); + AnthropicToolsHelper.applyOptions(request, options, null); - MessageCreateParams params = builder.build(); - assertTrue(params.temperature().isPresent()); - assertEquals(0.8, params.temperature().get(), 0.001); - assertTrue(params.topP().isPresent()); - assertEquals(0.95, params.topP().get(), 0.001); - assertEquals(3000, params.maxTokens()); + assertEquals(0.8, request.getTemperature(), 0.001); + assertEquals(0.95, request.getTopP(), 0.001); + assertEquals(3000, request.getMaxTokens()); } @Test void testApplyOptionsWithDefaultFallback() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); GenerateOptions defaultOptions = GenerateOptions.builder().temperature(0.5).topP(0.9).build(); // No options provided, should use default - AnthropicToolsHelper.applyOptions(builder, null, defaultOptions); + AnthropicToolsHelper.applyOptions(request, null, defaultOptions); - MessageCreateParams params = builder.build(); - assertTrue(params.temperature().isPresent()); - assertEquals(0.5, params.temperature().get(), 0.001); - assertTrue(params.topP().isPresent()); - assertEquals(0.9, params.topP().get(), 0.001); + assertEquals(0.5, request.getTemperature(), 0.001); + assertEquals(0.9, request.getTopP(), 0.001); } @Test void testApplyOptionsOverridesDefault() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); GenerateOptions options = GenerateOptions.builder().temperature(0.7).build(); GenerateOptions defaultOptions = GenerateOptions.builder().temperature(0.5).build(); // Options should override default - AnthropicToolsHelper.applyOptions(builder, options, defaultOptions); + AnthropicToolsHelper.applyOptions(request, options, defaultOptions); - MessageCreateParams params = builder.build(); - assertTrue(params.temperature().isPresent()); - assertEquals(0.7, params.temperature().get(), 0.001); + assertEquals(0.7, request.getTemperature(), 0.001); } @Test void testApplyToolsWithComplexParameters() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); Map properties = new HashMap<>(); properties.put("name", Map.of("type", "string", "description", "Person name")); @@ -310,207 +287,57 @@ void testApplyToolsWithComplexParameters() { .parameters(parameters) .build(); - AnthropicToolsHelper.applyTools(builder, List.of(schema), null); + AnthropicToolsHelper.applyTools(request, List.of(schema), null); - MessageCreateParams params = builder.build(); - assertTrue(params.tools().isPresent()); - assertEquals(1, params.tools().get().size()); - assertTrue(params.tools().get().get(0).isTool()); - Tool tool = params.tools().get().get(0).asTool(); - assertEquals("create_person", tool.name()); - // Note: inputSchema validation is handled by Anthropic SDK during API calls + List tools = request.getTools(); + assertNotNull(tools); + assertEquals(1, tools.size()); + AnthropicTool tool = tools.get(0); + assertEquals("create_person", tool.getName()); + assertNotNull(tool.getInputSchema()); } // ==================== New Parameters Tests ==================== @Test void testApplyOptionsWithTopK() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); GenerateOptions options = GenerateOptions.builder().topK(40).build(); - AnthropicToolsHelper.applyOptions(builder, options, null); - - MessageCreateParams params = builder.build(); - assertTrue(params.topK().isPresent()); - assertEquals(40L, params.topK().get()); - } - - @Test - void testApplyOptionsWithAdditionalHeaders() { - MessageCreateParams.Builder builder = createBuilder(); - - GenerateOptions options = - GenerateOptions.builder() - .additionalHeader("X-Custom-Header", "custom-value") - .additionalHeader("X-Request-Id", "req-123") - .build(); - - AnthropicToolsHelper.applyOptions(builder, options, null); - - // Build should succeed with additional headers applied - MessageCreateParams params = builder.build(); - assertNotNull(params); - } - - @Test - void testApplyOptionsWithAdditionalBodyParams() { - MessageCreateParams.Builder builder = createBuilder(); - - GenerateOptions options = - GenerateOptions.builder() - .additionalBodyParam("custom_param", "value1") - .additionalBodyParam("nested_param", Map.of("key", "value")) - .build(); - - AnthropicToolsHelper.applyOptions(builder, options, null); - - // Build should succeed with additional body params applied - MessageCreateParams params = builder.build(); - assertNotNull(params); - } - - @Test - void testApplyOptionsWithAdditionalQueryParams() { - MessageCreateParams.Builder builder = createBuilder(); - - GenerateOptions options = - GenerateOptions.builder() - .additionalQueryParam("api_version", "2024-01-01") - .additionalQueryParam("debug", "true") - .build(); - - AnthropicToolsHelper.applyOptions(builder, options, null); - - // Build should succeed with additional query params applied - MessageCreateParams params = builder.build(); - assertNotNull(params); - } - - @Test - void testApplyOptionsWithAllNewParameters() { - MessageCreateParams.Builder builder = createBuilder(); - - GenerateOptions options = - GenerateOptions.builder() - .temperature(0.8) - .topK(50) - .additionalHeader("X-Api-Key", "secret") - .additionalBodyParam("stream", true) - .additionalQueryParam("version", "v1") - .build(); - - AnthropicToolsHelper.applyOptions(builder, options, null); + AnthropicToolsHelper.applyOptions(request, options, null); - MessageCreateParams params = builder.build(); - assertTrue(params.temperature().isPresent()); - assertEquals(0.8, params.temperature().get(), 0.001); - assertTrue(params.topK().isPresent()); - assertEquals(50L, params.topK().get()); + assertEquals(40, request.getTopK()); } @Test void testApplyOptionsTopKFromDefaultOptions() { - MessageCreateParams.Builder builder = createBuilder(); + AnthropicRequest request = new AnthropicRequest(); GenerateOptions options = GenerateOptions.builder().temperature(0.5).build(); GenerateOptions defaultOptions = GenerateOptions.builder().topK(30).build(); - AnthropicToolsHelper.applyOptions(builder, options, defaultOptions); - - MessageCreateParams params = builder.build(); - assertTrue(params.temperature().isPresent()); - assertEquals(0.5, params.temperature().get(), 0.001); - assertTrue(params.topK().isPresent()); - assertEquals(30L, params.topK().get()); - } - - @Test - void testApplyOptionsWithEmptyAdditionalParams() { - MessageCreateParams.Builder builder = createBuilder(); - - GenerateOptions options = GenerateOptions.builder().temperature(0.5).build(); + AnthropicToolsHelper.applyOptions(request, options, defaultOptions); - // Should handle empty additional params gracefully - AnthropicToolsHelper.applyOptions(builder, options, null); - - MessageCreateParams params = builder.build(); - assertTrue(params.temperature().isPresent()); - assertEquals(0.5, params.temperature().get(), 0.001); + assertEquals(0.5, request.getTemperature(), 0.001); + assertEquals(30, request.getTopK()); } @Test - void testApplyOptionsMergesAdditionalHeadersFromBothOptionsAndDefault() { - MessageCreateParams.Builder builder = createBuilder(); - - // Default options has header A and B - GenerateOptions defaultOptions = - GenerateOptions.builder() - .additionalHeader("X-Header-A", "value-a-default") - .additionalHeader("X-Header-B", "value-b") - .build(); - - // Options has header A (override) and C (new) - GenerateOptions options = - GenerateOptions.builder() - .additionalHeader("X-Header-A", "value-a-override") - .additionalHeader("X-Header-C", "value-c") - .build(); - - // Should merge: A=override, B=value-b, C=value-c - AnthropicToolsHelper.applyOptions(builder, options, defaultOptions); - - MessageCreateParams params = builder.build(); - assertNotNull(params); - } - - @Test - void testApplyOptionsMergesAdditionalBodyParamsFromBothOptionsAndDefault() { - MessageCreateParams.Builder builder = createBuilder(); - - // Default options has param A and B - GenerateOptions defaultOptions = - GenerateOptions.builder() - .additionalBodyParam("param_a", "value-a-default") - .additionalBodyParam("param_b", "value-b") - .build(); - - // Options has param A (override) and C (new) - GenerateOptions options = - GenerateOptions.builder() - .additionalBodyParam("param_a", "value-a-override") - .additionalBodyParam("param_c", "value-c") - .build(); - - // Should merge: A=override, B=value-b, C=value-c - AnthropicToolsHelper.applyOptions(builder, options, defaultOptions); - - MessageCreateParams params = builder.build(); - assertNotNull(params); - } - - @Test - void testApplyOptionsMergesAdditionalQueryParamsFromBothOptionsAndDefault() { - MessageCreateParams.Builder builder = createBuilder(); - - // Default options has query param A and B - GenerateOptions defaultOptions = - GenerateOptions.builder() - .additionalQueryParam("query_a", "value-a-default") - .additionalQueryParam("query_b", "value-b") - .build(); + void testApplyOptionsWithAdditionalBodyParamsPreservesExistingMetadata() { + AnthropicRequest request = new AnthropicRequest(); + request.setMetadata(new HashMap<>(Map.of("trace_id", "trace-123"))); - // Options has query param A (override) and C (new) GenerateOptions options = GenerateOptions.builder() - .additionalQueryParam("query_a", "value-a-override") - .additionalQueryParam("query_c", "value-c") + .additionalBodyParam("custom_flag", true) + .additionalBodyParam("priority", "high") .build(); - // Should merge: A=override, B=value-b, C=value-c - AnthropicToolsHelper.applyOptions(builder, options, defaultOptions); + AnthropicToolsHelper.applyOptions(request, options, null); - MessageCreateParams params = builder.build(); - assertNotNull(params); + assertEquals("trace-123", request.getMetadata().get("trace_id")); + assertEquals(true, request.getMetadata().get("custom_flag")); + assertEquals("high", request.getMetadata().get("priority")); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/dto/AnthropicContentTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/dto/AnthropicContentTest.java new file mode 100644 index 000000000..0cbfd4753 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/dto/AnthropicContentTest.java @@ -0,0 +1,241 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link AnthropicContent}. + */ +class AnthropicContentTest { + + @Test + void testTextFactoryMethod() { + AnthropicContent content = AnthropicContent.text("Hello, world!"); + + assertEquals("text", content.getType()); + assertEquals("Hello, world!", content.getText()); + assertNull(content.getSource()); + assertNull(content.getId()); + assertNull(content.getName()); + assertNull(content.getInput()); + assertNull(content.getToolUseId()); + assertNull(content.getContent()); + assertNull(content.getIsError()); + assertNull(content.getThinking()); + } + + @Test + void testImageFactoryMethod() { + AnthropicContent content = AnthropicContent.image("image/png", "base64data"); + + assertEquals("image", content.getType()); + assertNull(content.getText()); + assertNotNull(content.getSource()); + assertEquals("base64", content.getSource().getType()); + assertEquals("image/png", content.getSource().getMediaType()); + assertEquals("base64data", content.getSource().getData()); + assertNull(content.getId()); + assertNull(content.getName()); + assertNull(content.getInput()); + assertNull(content.getToolUseId()); + assertNull(content.getContent()); + assertNull(content.getIsError()); + assertNull(content.getThinking()); + } + + @Test + void testToolUseFactoryMethod() { + Map input = Map.of("query", "test", "limit", 10); + AnthropicContent content = AnthropicContent.toolUse("tool_123", "search", input); + + assertEquals("tool_use", content.getType()); + assertNull(content.getText()); + assertNull(content.getSource()); + assertEquals("tool_123", content.getId()); + assertEquals("search", content.getName()); + assertEquals(input, content.getInput()); + assertNull(content.getToolUseId()); + assertNull(content.getContent()); + assertNull(content.getIsError()); + assertNull(content.getThinking()); + } + + @Test + void testToolResultFactoryMethodWithStringContent() { + String resultContent = "Search results"; + AnthropicContent content = AnthropicContent.toolResult("tool_123", resultContent, false); + + assertEquals("tool_result", content.getType()); + assertNull(content.getText()); + assertNull(content.getSource()); + assertNull(content.getId()); + assertNull(content.getName()); + assertNull(content.getInput()); + assertEquals("tool_123", content.getToolUseId()); + assertEquals(resultContent, content.getContent()); + assertEquals(false, content.getIsError()); + assertNull(content.getThinking()); + } + + @Test + void testToolResultFactoryMethodWithArrayContent() { + AnthropicContent[] contentArray = + new AnthropicContent[] { + AnthropicContent.text("Result line 1"), AnthropicContent.text("Result line 2") + }; + AnthropicContent content = AnthropicContent.toolResult("tool_123", contentArray, null); + + assertEquals("tool_result", content.getType()); + assertEquals("tool_123", content.getToolUseId()); + assertNotNull(content.getContent()); + assertNull(content.getIsError()); + } + + @Test + void testToolResultFactoryMethodWithError() { + String errorContent = "Tool execution failed"; + AnthropicContent content = AnthropicContent.toolResult("tool_123", errorContent, true); + + assertEquals("tool_result", content.getType()); + assertEquals("tool_123", content.getToolUseId()); + assertEquals(errorContent, content.getContent()); + assertEquals(true, content.getIsError()); + } + + @Test + void testThinkingFactoryMethod() { + AnthropicContent content = AnthropicContent.thinking("Let me think about this..."); + + assertEquals("thinking", content.getType()); + assertNull(content.getText()); + assertNull(content.getSource()); + assertNull(content.getId()); + assertNull(content.getName()); + assertNull(content.getInput()); + assertNull(content.getToolUseId()); + assertNull(content.getContent()); + assertNull(content.getIsError()); + assertEquals("Let me think about this...", content.getThinking()); + } + + @Test + void testSettersAndGetters() { + AnthropicContent content = new AnthropicContent(); + + content.setType("custom"); + assertEquals("custom", content.getType()); + + content.setText("Custom text"); + assertEquals("Custom text", content.getText()); + + AnthropicContent.ImageSource source = + new AnthropicContent.ImageSource("image/jpeg", "data"); + content.setSource(source); + assertEquals(source, content.getSource()); + + content.setId("custom_id"); + assertEquals("custom_id", content.getId()); + + content.setName("custom_name"); + assertEquals("custom_name", content.getName()); + + Map input = Map.of("key", "value"); + content.setInput(input); + assertEquals(input, content.getInput()); + + content.setToolUseId("tool_use_id"); + assertEquals("tool_use_id", content.getToolUseId()); + + Object contentObj = "content object"; + content.setContent(contentObj); + assertEquals(contentObj, content.getContent()); + + content.setIsError(true); + assertEquals(true, content.getIsError()); + + content.setThinking("thinking process"); + assertEquals("thinking process", content.getThinking()); + } + + @Test + void testImageSourceDefaultConstructor() { + AnthropicContent.ImageSource source = new AnthropicContent.ImageSource(); + + assertEquals("base64", source.getType()); + assertNull(source.getMediaType()); + assertNull(source.getData()); + } + + @Test + void testImageSourceParameterizedConstructor() { + AnthropicContent.ImageSource source = + new AnthropicContent.ImageSource("image/webp", "webpdata"); + + assertEquals("base64", source.getType()); + assertEquals("image/webp", source.getMediaType()); + assertEquals("webpdata", source.getData()); + } + + @Test + void testImageSourceSettersAndGetters() { + AnthropicContent.ImageSource source = new AnthropicContent.ImageSource(); + + source.setType("custom_type"); + assertEquals("custom_type", source.getType()); + + source.setMediaType("image/gif"); + assertEquals("image/gif", source.getMediaType()); + + source.setData("gifdata"); + assertEquals("gifdata", source.getData()); + } + + @Test + void testEmptyContentConstruction() { + AnthropicContent content = new AnthropicContent(); + + assertNull(content.getType()); + assertNull(content.getText()); + assertNull(content.getSource()); + assertNull(content.getId()); + assertNull(content.getName()); + assertNull(content.getInput()); + assertNull(content.getToolUseId()); + assertNull(content.getContent()); + assertNull(content.getIsError()); + assertNull(content.getThinking()); + } + + @Test + void testContentWithMultipleFieldsSet() { + AnthropicContent content = AnthropicContent.text("Main text"); + + // Additional setters to verify all fields can coexist + content.setId("extra_id"); + content.setName("extra_name"); + + assertEquals("text", content.getType()); + assertEquals("Main text", content.getText()); + assertEquals("extra_id", content.getId()); + assertEquals("extra_name", content.getName()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/dto/AnthropicRequestTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/dto/AnthropicRequestTest.java new file mode 100644 index 000000000..d6f3aff39 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/anthropic/dto/AnthropicRequestTest.java @@ -0,0 +1,212 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.formatter.anthropic.dto; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link AnthropicRequest}. + * + *

Tests cover defensive copying, null handling, and basic DTO functionality. + */ +class AnthropicRequestTest { + + @Test + void testGetMetadataReturnsUnmodifiableView() { + // Given: An AnthropicRequest with metadata + AnthropicRequest request = new AnthropicRequest(); + Map originalMetadata = new HashMap<>(); + originalMetadata.put("user_id", "12345"); + originalMetadata.put("request_id", "abc-def"); + request.setMetadata(originalMetadata); + + // When: Getting the metadata + Map returnedMetadata = request.getMetadata(); + + // Then: The returned map should be unmodifiable + assertNotNull(returnedMetadata, "Metadata should not be null"); + assertNotNull(returnedMetadata.getClass(), "Map class should not be null"); + + // Test that it's actually unmodifiable by trying to remove an entry + assertThrows( + UnsupportedOperationException.class, + () -> returnedMetadata.remove("user_id"), + "Returned map should be unmodifiable - remove() should throw"); + + // And: The original map should still be modifiable + assertDoesNotThrow(() -> originalMetadata.put("another_key", "another_value")); + + // And: Changes to the caller-owned map should not affect the request metadata snapshot + originalMetadata.put("modified_key", "modified_value"); + assertFalse(returnedMetadata.containsKey("modified_key")); + assertFalse(request.getMetadata().containsKey("modified_key")); + } + + @Test + void testGetMetadataWhenNull() { + // Given: An AnthropicRequest without metadata + AnthropicRequest request = new AnthropicRequest(); + + // When: Getting the metadata + Map metadata = request.getMetadata(); + + // Then: Should return null + assertNull(metadata, "Metadata should be null when not set"); + } + + @Test + void testSetAndGetMetadata() { + // Given: An AnthropicRequest + AnthropicRequest request = new AnthropicRequest(); + Map metadata = new HashMap<>(); + metadata.put("key1", "value1"); + metadata.put("key2", 42); + + // When: Setting the metadata + request.setMetadata(metadata); + + // Then: Should be retrievable and contain the same values + Map retrieved = request.getMetadata(); + assertNotNull(retrieved); + assertEquals(2, retrieved.size()); + assertEquals("value1", retrieved.get("key1")); + assertEquals(42, retrieved.get("key2")); + } + + @Test + void testSetMetadataWithNull() { + // Given: An AnthropicRequest with metadata + AnthropicRequest request = new AnthropicRequest(); + Map metadata = new HashMap<>(); + metadata.put("key", "value"); + request.setMetadata(metadata); + + // When: Setting metadata to null + request.setMetadata(null); + + // Then: getMetadata should return null + assertNull(request.getMetadata()); + } + + @Test + void testMultipleGetMetadataCallsReturnDifferentUnmodifiableViews() { + // Given: An AnthropicRequest with metadata + AnthropicRequest request = new AnthropicRequest(); + Map metadata = new HashMap<>(); + metadata.put("key", "value"); + request.setMetadata(metadata); + + // When: Getting metadata multiple times + Map view1 = request.getMetadata(); + Map view2 = request.getMetadata(); + + // Then: Both should be unmodifiable and represent the same underlying data + assertNotNull(view1); + assertNotNull(view2); + assertEquals(view1.size(), view2.size()); + assertEquals(view1.get("key"), view2.get("key")); + + // Both should be unmodifiable - test remove() operation + assertThrows(UnsupportedOperationException.class, () -> view1.remove("key")); + assertThrows(UnsupportedOperationException.class, () -> view2.remove("key")); + } + + @Test + void testGetMetadataReturnsDefensiveSnapshot() { + AnthropicRequest request = new AnthropicRequest(); + request.setMetadata(new HashMap<>(Map.of("key", "value"))); + + Map snapshot = request.getMetadata(); + request.setMetadata(new HashMap<>(Map.of("key", "updated"))); + + assertEquals("value", snapshot.get("key")); + assertEquals("updated", request.getMetadata().get("key")); + } + + @Test + void testRequestWithAllFields() { + // Given: A fully populated AnthropicRequest + AnthropicRequest request = new AnthropicRequest(); + request.setModel("claude-sonnet-4-5-20250929"); + request.setMaxTokens(4096); + request.setTemperature(0.7); + request.setStream(true); + + Map metadata = new HashMap<>(); + metadata.put("user_id", "test-user"); + request.setMetadata(metadata); + + // When: Getting all fields + // Then: All should be correctly set + assertEquals("claude-sonnet-4-5-20250929", request.getModel()); + assertEquals(4096, request.getMaxTokens()); + assertEquals(0.7, request.getTemperature()); + assertEquals(true, request.getStream()); + assertNotNull(request.getMetadata()); + assertEquals("test-user", request.getMetadata().get("user_id")); + } + + @Test + void testRequestDefaults() { + // Given: A new AnthropicRequest with no fields set + AnthropicRequest request = new AnthropicRequest(); + + // When: Getting all fields + // Then: All should be null or default values + assertNull(request.getModel()); + assertNull(request.getMessages()); + assertNull(request.getMaxTokens()); + assertNull(request.getTemperature()); + assertNull(request.getTopP()); + assertNull(request.getTopK()); + assertNull(request.getSystem()); + assertNull(request.getTools()); + assertNull(request.getToolChoice()); + assertNull(request.getStream()); + assertNull(request.getStopSequences()); + assertNull(request.getMetadata()); + } + + @Test + void testMetadataImmutabilityDoesNotAffectSetter() { + // Given: An AnthropicRequest with initial metadata + AnthropicRequest request = new AnthropicRequest(); + Map initialMetadata = new HashMap<>(); + initialMetadata.put("initial", "value"); + request.setMetadata(initialMetadata); + + // When: Getting unmodifiable view, then setting new metadata + Map unmodifiableView = request.getMetadata(); + Map newMetadata = new HashMap<>(); + newMetadata.put("new", "data"); + request.setMetadata(newMetadata); + + // Then: The new metadata should be retrievable + Map retrieved = request.getMetadata(); + assertNotNull(retrieved); + assertEquals("data", retrieved.get("new")); + assertFalse(retrieved.containsKey("initial")); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/AnthropicClientTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/AnthropicClientTest.java new file mode 100644 index 000000000..22a5b7b8e --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/model/AnthropicClientTest.java @@ -0,0 +1,330 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.agentscope.core.formatter.anthropic.dto.AnthropicContent; +import io.agentscope.core.formatter.anthropic.dto.AnthropicMessage; +import io.agentscope.core.formatter.anthropic.dto.AnthropicRequest; +import io.agentscope.core.formatter.anthropic.dto.AnthropicResponse; +import io.agentscope.core.model.transport.HttpRequest; +import io.agentscope.core.model.transport.HttpResponse; +import io.agentscope.core.model.transport.HttpTransport; +import io.agentscope.core.model.transport.HttpTransportException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +/** + * Unit tests for {@link AnthropicClient}. + */ +class AnthropicClientTest { + + private static final String TEST_API_KEY = "test-api-key"; + private static final String TEST_BASE_URL = "https://test.api.anthropic.com"; + + private HttpTransport mockTransport; + private AnthropicClient client; + + @BeforeEach + void setUp() { + mockTransport = mock(HttpTransport.class); + client = new AnthropicClient(mockTransport); + } + + @Test + void testConstructorWithDefaultTransport() { + AnthropicClient defaultClient = new AnthropicClient(); + assertNotNull(defaultClient.getTransport()); + } + + @Test + void testGetTransport() { + assertEquals(mockTransport, client.getTransport()); + } + + @Test + void testSuccessfulCall() throws Exception { + // Arrange + AnthropicRequest request = createTestRequest(); + String responseBody = createSuccessResponseJson(); + + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.getStatusCode()).thenReturn(200); + when(mockResponse.getBody()).thenReturn(responseBody); + + when(mockTransport.execute(any(HttpRequest.class))).thenReturn(mockResponse); + + // Act + AnthropicResponse response = client.call(TEST_API_KEY, null, request); + + // Assert + assertNotNull(response); + assertEquals("msg_123", response.getId()); + assertEquals(1, response.getContent().size()); + assertEquals("Hello, world!", response.getContent().get(0).getText()); + assertEquals(100, response.getUsage().getInputTokens()); + assertEquals(50, response.getUsage().getOutputTokens()); + + // Verify request was built correctly + verify(mockTransport).execute(any(HttpRequest.class)); + } + + @Test + void testCallWithCustomBaseUrl() throws Exception { + // Arrange + AnthropicRequest request = createTestRequest(); + String responseBody = createSuccessResponseJson(); + + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.getStatusCode()).thenReturn(200); + when(mockResponse.getBody()).thenReturn(responseBody); + + when(mockTransport.execute(any(HttpRequest.class))).thenReturn(mockResponse); + + // Act + AnthropicResponse response = client.call(TEST_API_KEY, TEST_BASE_URL, request); + + // Assert + assertNotNull(response); + assertEquals("msg_123", response.getId()); + } + + @Test + void testCallWithGenerateOptions() throws Exception { + // Arrange + AnthropicRequest request = createTestRequest(); + String responseBody = createSuccessResponseJson(); + + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.getStatusCode()).thenReturn(200); + when(mockResponse.getBody()).thenReturn(responseBody); + + when(mockTransport.execute(any(HttpRequest.class))).thenReturn(mockResponse); + + GenerateOptions options = + GenerateOptions.builder() + .apiKey("options-api-key") + .baseUrl("https://options.api.com") + .build(); + + // Act + AnthropicResponse response = client.call(TEST_API_KEY, TEST_BASE_URL, request, options); + + // Assert + assertNotNull(response); + assertEquals("msg_123", response.getId()); + } + + @Test + void testCallWithNullRequest() { + assertThrows( + NullPointerException.class, + () -> { + client.call(TEST_API_KEY, null, null); + }); + } + + @Test + void testCallWithFailedResponse() throws Exception { + // Arrange + AnthropicRequest request = createTestRequest(); + String errorBody = "{\"error\":{\"message\":\"Invalid request\"}}"; + + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.isSuccessful()).thenReturn(false); + when(mockResponse.getStatusCode()).thenReturn(400); + when(mockResponse.getBody()).thenReturn(errorBody); + + when(mockTransport.execute(any(HttpRequest.class))).thenReturn(mockResponse); + + // Act & Assert + ModelException exception = + assertThrows( + ModelException.class, + () -> { + client.call(TEST_API_KEY, null, request); + }); + + assertTrue(exception.getMessage().contains("400")); + assertTrue(exception.getMessage().contains("Invalid request")); + } + + @Test + void testCallWithEmptyResponseBody() throws Exception { + // Arrange + AnthropicRequest request = createTestRequest(); + + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.getStatusCode()).thenReturn(200); + when(mockResponse.getBody()).thenReturn(""); + + when(mockTransport.execute(any(HttpRequest.class))).thenReturn(mockResponse); + + // Act & Assert + ModelException exception = + assertThrows( + ModelException.class, + () -> { + client.call(TEST_API_KEY, null, request); + }); + + assertTrue(exception.getMessage().contains("empty response body")); + } + + @Test + void testCallWithInvalidJsonResponse() throws Exception { + // Arrange + AnthropicRequest request = createTestRequest(); + + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.getStatusCode()).thenReturn(200); + when(mockResponse.getBody()).thenReturn("invalid json"); + + when(mockTransport.execute(any(HttpRequest.class))).thenReturn(mockResponse); + + // Act & Assert + ModelException exception = + assertThrows( + ModelException.class, + () -> { + client.call(TEST_API_KEY, null, request); + }); + + assertTrue(exception.getMessage().contains("Failed to parse")); + } + + @Test + void testStreamWithSuccess() throws Exception { + // Arrange + AnthropicRequest request = createTestRequest(); + String streamData = + "{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}"; + + // HttpTransport.stream() should handle SSE parsing and return data without "data: " prefix + when(mockTransport.stream(any(HttpRequest.class))) + .thenReturn(Flux.just(streamData, "[DONE]")); + + // Act + Flux eventFlux = + client.stream(TEST_API_KEY, null, request, null); + + // Assert + List events = + eventFlux.collectList().block(); + assertNotNull(events); + assertEquals(1, events.size()); + assertEquals("content_block_delta", events.get(0).getType()); + } + + @Test + void testStreamWithNullRequest() { + assertThrows( + NullPointerException.class, + () -> { + client.stream(TEST_API_KEY, null, null, null); + }); + } + + @Test + void testStreamWithTransportError() throws Exception { + // Arrange + AnthropicRequest request = createTestRequest(); + + when(mockTransport.stream(any(HttpRequest.class))) + .thenReturn(Flux.error(new HttpTransportException("Connection failed"))); + + // Act + Flux eventFlux = + client.stream(TEST_API_KEY, null, request, null); + + // Assert + ModelException exception = + assertThrows( + ModelException.class, + () -> { + eventFlux.blockFirst(); + }); + + assertTrue(exception.getMessage().contains("HTTP transport error")); + } + + @Test + void testDefaultConstants() { + assertEquals("https://api.anthropic.com", AnthropicClient.DEFAULT_BASE_URL); + assertEquals("/v1/messages", AnthropicClient.MESSAGES_ENDPOINT); + assertEquals("2023-06-01", AnthropicClient.DEFAULT_API_VERSION); + } + + @Test + void testNormalizeBaseUrl() throws Exception { + // We can't directly test the private method, but we can verify behavior + // through the public API by using different base URLs + AnthropicRequest request = createTestRequest(); + String responseBody = createSuccessResponseJson(); + + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.getStatusCode()).thenReturn(200); + when(mockResponse.getBody()).thenReturn(responseBody); + + when(mockTransport.execute(any(HttpRequest.class))).thenReturn(mockResponse); + + // Test with trailing slash + client.call(TEST_API_KEY, "https://api.example.com/", request); + + // Test without trailing slash + client.call(TEST_API_KEY, "https://api.example.com", request); + } + + private AnthropicRequest createTestRequest() { + AnthropicRequest request = new AnthropicRequest(); + request.setModel("claude-3-5-sonnet-20241022"); + request.setMaxTokens(1024); + + AnthropicMessage message = + new AnthropicMessage("user", List.of(AnthropicContent.text("Hello"))); + request.setMessages(List.of(message)); + + return request; + } + + private String createSuccessResponseJson() { + return "{" + + "\"id\":\"msg_123\"," + + "\"type\":\"message\"," + + "\"role\":\"assistant\"," + + "\"content\":[{\"type\":\"text\",\"text\":\"Hello, world!\"}]," + + "\"model\":\"claude-3-5-sonnet-20241022\"," + + "\"stop_reason\":\"end_turn\"," + + "\"usage\":{\"input_tokens\":100,\"output_tokens\":50}" + + "}"; + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ChatModelNonStreamingBlockingBehaviorTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ChatModelNonStreamingBlockingBehaviorTest.java index 51f76ce03..457ad4446 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/model/ChatModelNonStreamingBlockingBehaviorTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ChatModelNonStreamingBlockingBehaviorTest.java @@ -160,6 +160,71 @@ void testOpenAIChatModelNonBlocking() throws Exception { "OpenAIChatModel should be NON-BLOCKING"); } + @Test + @DisplayName("AnthropicChatModel - Should be NON-BLOCKING in non-streaming mode") + void testAnthropicChatModelNonBlocking() throws Exception { + String responseJson = + """ + { + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello!" + } + ], + "model": "claude-sonnet-4-5-20250929", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 10, + "output_tokens": 5 + } + } + """; + + mockServer.enqueue( + new MockResponse() + .setBody(responseJson) + .setHeader("Content-Type", "application/json") + .setBodyDelay(RESPONSE_DELAY_MS, TimeUnit.MILLISECONDS)); + + AnthropicChatModel model = + AnthropicChatModel.builder() + .apiKey("test-key") + .modelName("claude-sonnet-4-5-20250929") + .stream(false) + .baseUrl(mockServer.url("/").toString().replaceAll("/$", "")) + .build(); + + List messages = + List.of( + Msg.builder() + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("Hello").build())) + .build()); + + CountDownLatch latch = new CountDownLatch(1); + String currentThreadName = Thread.currentThread().getName(); + AtomicReference streamThreadName = new AtomicReference<>(); + + model.stream(messages, null, null) + .subscribe( + response -> { + streamThreadName.set(Thread.currentThread().getName()); + latch.countDown(); + }, + error -> latch.countDown()); + + latch.await(3, TimeUnit.SECONDS); + assertNotNull(streamThreadName.get()); + assertNotEquals( + currentThreadName, + streamThreadName.get(), + "AnthropicChatModel should be NON-BLOCKING"); + } + @Test @DisplayName("OllamaChatModel - Should be NON-BLOCKING in non-streaming mode") void testOllamaChatModelNonBlocking() throws Exception {