diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java index 8aedc14f1..cbe8ea2a6 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java @@ -22,26 +22,23 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.anthropic.api.AnthropicApi; -import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletion; +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicMessage; import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest; -import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent; -import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent.Type; -import org.springframework.ai.anthropic.api.AnthropicApi.RequestMessage; +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock.ContentBlockType; import org.springframework.ai.anthropic.api.AnthropicApi.Role; -import org.springframework.ai.anthropic.api.AnthropicApi.StreamResponse; -import org.springframework.ai.anthropic.api.AnthropicApi.Usage; import org.springframework.ai.anthropic.metadata.AnthropicChatResponseMetadata; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.chat.messages.MessageType; -import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.ModelOptionsUtils; @@ -64,7 +61,7 @@ import reactor.core.publisher.Flux; * @since 1.0.0 */ public class AnthropicChatModel extends - AbstractFunctionCallSupport> + AbstractFunctionCallSupport> implements ChatModel { private static final Logger logger = LoggerFactory.getLogger(AnthropicChatModel.class); @@ -151,7 +148,7 @@ public class AnthropicChatModel extends ChatCompletionRequest request = createRequest(prompt, false); return this.retryTemplate.execute(ctx -> { - ResponseEntity completionEntity = this.callWithFunctionSupport(request); + ResponseEntity completionEntity = this.callWithFunctionSupport(request); return toChatResponse(completionEntity.getBody()); }); } @@ -163,87 +160,17 @@ public class AnthropicChatModel extends return this.retryTemplate.execute(ctx -> { - Flux response = this.anthropicApi.chatCompletionStream(request); + Flux response = this.anthropicApi.chatCompletionStream(request); - AtomicReference chatCompletionReference = new AtomicReference<>(); - - return response.map(chunk -> chunkToChatCompletion(chunk, chatCompletionReference)) - .switchMap( - cc -> handleFunctionCallOrReturnStream(request, Flux.just(ResponseEntity.of(Optional.of(cc))))) + return response + .switchMap(chatCompletionResponse -> handleFunctionCallOrReturnStream(request, + Flux.just(ResponseEntity.of(Optional.of(chatCompletionResponse))))) .map(ResponseEntity::getBody) .map(this::toChatResponse); }); } - private ChatCompletion chunkToChatCompletion(StreamResponse chunk, - AtomicReference chatCompletionReference) { - - // https://docs.anthropic.com/claude/reference/messages-streaming - - if (chunk.type().equals("message_start")) { - chatCompletionReference.set(new ChatCompletionBuilder()); - chatCompletionReference.get() - .withType(chunk.type()) - .withId(chunk.message().id()) - .withRole(chunk.message().role()) - .withModel(chunk.message().model()) - .withUsage(chunk.message().usage()) - .withContent(new ArrayList<>()); - } - else if (chunk.type().equals("content_block_start")) { - var content = new MediaContent(chunk.contentBlock().type(), null, chunk.contentBlock().text(), - chunk.index()); - chatCompletionReference.get().withType(chunk.type()).withContent(List.of(content)); - } - else if (chunk.type().equals("content_block_delta")) { - var content = new MediaContent(Type.TEXT_DELTA, null, (String) chunk.delta().get("text"), chunk.index()); - chatCompletionReference.get().withType(chunk.type()).withContent(List.of(content)); - } - else if (chunk.type().equals("message_delta")) { - - ChatCompletion delta = ModelOptionsUtils.mapToClass(chunk.delta(), ChatCompletion.class); - - chatCompletionReference.get().withType(chunk.type()); - if (chunk.usage() != null) { - var totalUsage = new Usage(chatCompletionReference.get().usage.inputTokens(), - chunk.usage().outputTokens()); - chatCompletionReference.get().withUsage(totalUsage); - } - if (chunk.mergedToolUses() != null) { - chatCompletionReference.get().withToolUses(chunk.mergedToolUses()); - } - if (delta.id() != null) { - chatCompletionReference.get().withId(delta.id()); - } - if (delta.role() != null) { - chatCompletionReference.get().withRole(delta.role()); - } - if (delta.model() != null) { - chatCompletionReference.get().withModel(delta.model()); - } - if (delta.content() != null) { - chatCompletionReference.get().withContent(delta.content()); - } - if (delta.stopReason() != null) { - chatCompletionReference.get().withStopReason(delta.stopReason()); - } - if (delta.stopSequence() != null) { - chatCompletionReference.get().withStopSequence(delta.stopSequence()); - } - } - else if (chunk.type().equals("message_stop")) { - if (chatCompletionReference.get().toolUses != null) { - chatCompletionReference.get().withContent(chatCompletionReference.get().toolUses); - } - } - else { - chatCompletionReference.get().withType(chunk.type()).withContent(List.of()); - } - - return chatCompletionReference.get().build(); - } - - private ChatResponse toChatResponse(ChatCompletion chatCompletion) { + private ChatResponse toChatResponse(ChatCompletionResponse chatCompletion) { if (chatCompletion == null) { logger.warn("Null chat completion returned"); return new ChatResponse(List.of()); @@ -274,20 +201,20 @@ public class AnthropicChatModel extends Set functionsForThisRequest = new HashSet<>(); - List userMessages = prompt.getInstructions() + List userMessages = prompt.getInstructions() .stream() .filter(m -> m.getMessageType() != MessageType.SYSTEM) .map(m -> { - List contents = new ArrayList<>(List.of(new MediaContent(m.getContent()))); + List contents = new ArrayList<>(List.of(new ContentBlock(m.getContent()))); if (!CollectionUtils.isEmpty(m.getMedia())) { - List mediaContent = m.getMedia() + List mediaContent = m.getMedia() .stream() - .map(media -> new MediaContent(media.getMimeType().toString(), + .map(media -> new ContentBlock(media.getMimeType().toString(), this.fromMediaData(media.getData()))) .toList(); contents.addAll(mediaContent); } - return new RequestMessage(contents, Role.valueOf(m.getMessageType().name())); + return new AnthropicMessage(contents, Role.valueOf(m.getMessageType().name())); }) .toList(); @@ -338,93 +265,18 @@ public class AnthropicChatModel extends }).toList(); } - private static class ChatCompletionBuilder { - - private String type; - - private String id; - - private Role role; - - private List content; - - private String model; - - private String stopReason; - - private String stopSequence; - - private Usage usage; - - private List toolUses; - - public ChatCompletionBuilder() { - } - - public ChatCompletionBuilder withType(String type) { - this.type = type; - return this; - } - - public ChatCompletionBuilder withId(String id) { - this.id = id; - return this; - } - - public ChatCompletionBuilder withRole(Role role) { - this.role = role; - return this; - } - - public ChatCompletionBuilder withContent(List content) { - this.content = content; - return this; - } - - public ChatCompletionBuilder withModel(String model) { - this.model = model; - return this; - } - - public ChatCompletionBuilder withStopReason(String stopReason) { - this.stopReason = stopReason; - return this; - } - - public ChatCompletionBuilder withStopSequence(String stopSequence) { - this.stopSequence = stopSequence; - return this; - } - - public ChatCompletionBuilder withUsage(Usage usage) { - this.usage = usage; - return this; - } - - public ChatCompletionBuilder withToolUses(List toolUses) { - this.toolUses = toolUses; - return this; - } - - public ChatCompletion build() { - return new ChatCompletion(this.id, this.type, this.role, this.content, this.model, this.stopReason, - this.stopSequence, this.usage); - } - - } - @Override protected ChatCompletionRequest doCreateToolResponseRequest(ChatCompletionRequest previousRequest, - RequestMessage responseMessage, List conversationHistory) { + AnthropicMessage responseMessage, List conversationHistory) { - List toolToUseList = responseMessage.content() + List toolToUseList = responseMessage.content() .stream() - .filter(c -> c.type() == MediaContent.Type.TOOL_USE) + .filter(c -> c.type() == ContentBlock.ContentBlockType.TOOL_USE) .toList(); - List toolResults = new ArrayList<>(); + List toolResults = new ArrayList<>(); - for (MediaContent toolToUse : toolToUseList) { + for (ContentBlock toolToUse : toolToUseList) { var functionCallId = toolToUse.id(); var functionName = toolToUse.name(); @@ -437,11 +289,11 @@ public class AnthropicChatModel extends String functionResponse = this.functionCallbackRegister.get(functionName) .call(ModelOptionsUtils.toJsonString(functionArguments)); - toolResults.add(new MediaContent(Type.TOOL_RESULT, functionCallId, functionResponse)); + toolResults.add(new ContentBlock(ContentBlockType.TOOL_RESULT, functionCallId, functionResponse)); } // Add the function response to the conversation. - conversationHistory.add(new RequestMessage(toolResults, Role.USER)); + conversationHistory.add(new AnthropicMessage(toolResults, Role.USER)); // Recursively call chatCompletionWithTools until the model doesn't call a // functions anymore. @@ -449,38 +301,36 @@ public class AnthropicChatModel extends } @Override - protected List doGetUserMessages(ChatCompletionRequest request) { + protected List doGetUserMessages(ChatCompletionRequest request) { return request.messages(); } @Override - protected RequestMessage doGetToolResponseMessage(ResponseEntity response) { - return new RequestMessage(response.getBody().content(), Role.ASSISTANT); + protected AnthropicMessage doGetToolResponseMessage(ResponseEntity response) { + return new AnthropicMessage(response.getBody().content(), Role.ASSISTANT); } @Override - protected ResponseEntity doChatCompletion(ChatCompletionRequest request) { + protected ResponseEntity doChatCompletion(ChatCompletionRequest request) { return this.anthropicApi.chatCompletionEntity(request); } @SuppressWarnings("null") @Override - protected boolean isToolFunctionCall(ResponseEntity response) { + protected boolean isToolFunctionCall(ResponseEntity response) { if (response == null || response.getBody() == null || CollectionUtils.isEmpty(response.getBody().content())) { return false; } - return response.getBody().content().stream().anyMatch(content -> content.type() == MediaContent.Type.TOOL_USE); + return response.getBody() + .content() + .stream() + .anyMatch(content -> content.type() == ContentBlock.ContentBlockType.TOOL_USE); } @Override - protected Flux> doChatCompletionStream(ChatCompletionRequest request) { + protected Flux> doChatCompletionStream(ChatCompletionRequest request) { - AtomicReference chatCompletionReference = new AtomicReference<>(); - - return this.anthropicApi.chatCompletionStream(request) - .map(chunk -> this.chunkToChatCompletion(chunk, chatCompletionReference)) - .map(Optional::ofNullable) - .map(ResponseEntity::of); + return this.anthropicApi.chatCompletionStream(request).map(Optional::ofNullable).map(ResponseEntity::of); } @Override diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java index 3c8e14ec3..f8d861bd3 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java @@ -15,18 +15,15 @@ */ package org.springframework.ai.anthropic.api; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Predicate; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.annotation.JsonProperty; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - +import org.springframework.ai.anthropic.api.StreamHelper.ChatCompletionResponseBuilder; import org.springframework.ai.model.ModelDescription; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.retry.RetryUtils; @@ -35,10 +32,20 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + /** * @author Christian Tzolov * @author Mariusz Bernacki @@ -152,6 +159,18 @@ public class AnthropicApi { } + /** + * The role of the author of this message. + */ + public enum Role { + + // @formatter:off + @JsonProperty("user") USER, + @JsonProperty("assistant") ASSISTANT + // @formatter:on + + } + /** * @param model The model that will complete your prompt. See the list of * models for @@ -195,7 +214,7 @@ public class AnthropicApi { @JsonInclude(Include.NON_NULL) public record ChatCompletionRequest( // @formatter:off @JsonProperty("model") String model, - @JsonProperty("messages") List messages, + @JsonProperty("messages") List messages, @JsonProperty("system") String system, @JsonProperty("max_tokens") Integer maxTokens, @JsonProperty("metadata") Metadata metadata, @@ -207,12 +226,12 @@ public class AnthropicApi { @JsonProperty("tools") List tools) { // @formatter:on - public ChatCompletionRequest(String model, List messages, String system, Integer maxTokens, + public ChatCompletionRequest(String model, List messages, String system, Integer maxTokens, Float temperature, Boolean stream) { this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null); } - public ChatCompletionRequest(String model, List messages, String system, Integer maxTokens, + public ChatCompletionRequest(String model, List messages, String system, Integer maxTokens, List stopSequences, Float temperature, Boolean stream) { this(model, messages, system, maxTokens, null, stopSequences, stream, temperature, null, null, null); } @@ -240,7 +259,7 @@ public class AnthropicApi { private String model; - private List messages; + private List messages; private String system; @@ -287,7 +306,7 @@ public class AnthropicApi { return this; } - public ChatCompletionRequestBuilder withMessages(List messages) { + public ChatCompletionRequestBuilder withMessages(List messages) { this.messages = messages; return this; } @@ -363,22 +382,23 @@ public class AnthropicApi { * types. */ @JsonInclude(Include.NON_NULL) - public record RequestMessage( // @formatter:off - @JsonProperty("content") List content, + public record AnthropicMessage( // @formatter:off + @JsonProperty("content") List content, @JsonProperty("role") Role role) { // @formatter:on } /** - * @param type the content type can be "text" or "image". + * @param type the content type can be "text", "image", "tool_use", "tool_result" or + * "text_delta". * @param source The source of the media content. Applicable for "image" types only. * @param text The text of the message. Applicable for "text" types only. * @param index The index of the content block. Applicable only for streaming * responses. */ @JsonInclude(Include.NON_NULL) - public record MediaContent( // @formatter:off - @JsonProperty("type") Type type, + public record ContentBlock( // @formatter:off + @JsonProperty("type") ContentBlockType type, @JsonProperty("source") Source source, @JsonProperty("text") String text, @@ -389,7 +409,6 @@ public class AnthropicApi { @JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("input") Map input, - @JsonProperty("input_json") String inputJson, // tool_result response only @JsonProperty("tool_use_id") String toolUseId, @@ -397,36 +416,36 @@ public class AnthropicApi { ) { // @formatter:on - public MediaContent(String mediaType, String data) { + public ContentBlock(String mediaType, String data) { this(new Source(mediaType, data)); } - public MediaContent(Source source) { - this(Type.IMAGE, source, null, null, null, null, null, null, null, null); + public ContentBlock(Source source) { + this(ContentBlockType.IMAGE, source, null, null, null, null, null, null, null); } - public MediaContent(String text) { - this(Type.TEXT, null, text, null, null, null, null, null, null, null); + public ContentBlock(String text) { + this(ContentBlockType.TEXT, null, text, null, null, null, null, null, null); } // Tool result - public MediaContent(Type type, String toolUseId, String content) { - this(type, null, null, null, null, null, null, null, toolUseId, content); + public ContentBlock(ContentBlockType type, String toolUseId, String content) { + this(type, null, null, null, null, null, null, toolUseId, content); } - public MediaContent(Type type, Source source, String text, Integer index) { - this(type, source, text, index, null, null, null, null, null, null); + public ContentBlock(ContentBlockType type, Source source, String text, Integer index) { + this(type, source, text, index, null, null, null, null, null); } // Tool use input JSON delta streaming - public MediaContent(Type type, Integer index, String id, String name, String inputJson) { - this(type, null, null, index, id, name, null, inputJson, null, null); + public ContentBlock(ContentBlockType type, String id, String name, Map input) { + this(type, null, null, null, id, name, input, null, null); } /** * The type of this message. */ - public enum Type { + public enum ContentBlockType { /** * Tool request @@ -452,6 +471,12 @@ public class AnthropicApi { @JsonProperty("text_delta") TEXT_DELTA, + /** + * Tool use input partial JSON delta streaming. + */ + @JsonProperty("input_json_delta") + INPUT_JSON_DELTA, + /** * Image message. */ @@ -504,11 +529,11 @@ public class AnthropicApi { * @param usage Input and output token usage. */ @JsonInclude(Include.NON_NULL) - public record ChatCompletion( // @formatter:off + public record ChatCompletionResponse( // @formatter:off @JsonProperty("id") String id, @JsonProperty("type") String type, @JsonProperty("role") Role role, - @JsonProperty("content") List content, + @JsonProperty("content") List content, @JsonProperty("model") String model, @JsonProperty("stop_reason") String stopReason, @JsonProperty("stop_sequence") String stopSequence, @@ -529,74 +554,313 @@ public class AnthropicApi { // @formatter:off } + + /////////////////////////////////////// + /// ERROR EVENT + /////////////////////////////////////// + /** - * Usage statistics with output only tokens for streamed completions. - * - * @param outputTokens The number of output tokens which were used in a completion. + * The evnt type of the streamed chunk. */ + public enum EventType { + + /** + * Message start event. Contains a Message object with empty content. + */ + @JsonProperty("message_start") + MESSAGE_START, + + /** + * Message delta event, indicating top-level changes to the final Message object. + */ + @JsonProperty("message_delta") + MESSAGE_DELTA, + + /** + * A final message stop event. + */ + @JsonProperty("message_stop") + MESSAGE_STOP, + + /** + * + */ + @JsonProperty("content_block_start") + CONTENT_BLOCK_START, + + /** + * + */ + @JsonProperty("content_block_delta") + CONTENT_BLOCK_DELTA, + + /** + * + */ + @JsonProperty("content_block_stop") + CONTENT_BLOCK_STOP, + + /** + * + */ + @JsonProperty("error") + ERROR, + + /** + * + */ + @JsonProperty("ping") + PING, + + /** + * Artifically created event to aggregate tool use events. + */ + TOOL_USE_AGGREATE; + + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", + visible = true) + @JsonSubTypes({ @JsonSubTypes.Type(value = ContentBlockStartEvent.class, name = "content_block_start"), + @JsonSubTypes.Type(value = ContentBlockDeltaEvent.class, name = "content_block_delta"), + @JsonSubTypes.Type(value = ContentBlockStopEvent.class, name = "content_block_stop"), + + @JsonSubTypes.Type(value = PingEvent.class, name = "ping"), + + @JsonSubTypes.Type(value = ErrorEvent.class, name = "error"), + + @JsonSubTypes.Type(value = MessageStartEvent.class, name = "message_start"), + @JsonSubTypes.Type(value = MessageDeltaEvent.class, name = "message_delta"), + @JsonSubTypes.Type(value = MessageStopEvent.class, name = "message_stop") }) + public interface StreamEvent { + + @JsonProperty("type") + EventType type(); + + } + + /////////////////////////////////////// + /// CONTENT_BLOCK EVENTS + /////////////////////////////////////// + + /** + * Special event used to aggregate multiple tool use events into a single event with + * list of aggregated ContentBlockToolUse. + */ + public static class ToolUseAggregationEvent implements StreamEvent { + + private Integer index; + + private String id; + + private String name; + + private String partialJson = ""; + + private List toolContentBlocks = new ArrayList<>(); + + @Override + public EventType type() { + return EventType.TOOL_USE_AGGREATE; + } + + public List getToolContentBlocks() { + return this.toolContentBlocks; + } + + public boolean isEmpty() { + return (this.index == null || this.id == null || this.name == null + || !StringUtils.hasText(this.partialJson)); + } + + ToolUseAggregationEvent withIndex(Integer index) { + this.index = index; + return this; + } + + ToolUseAggregationEvent withId(String id) { + this.id = id; + return this; + } + + ToolUseAggregationEvent withName(String name) { + this.name = name; + return this; + } + + ToolUseAggregationEvent appendPartialJson(String partialJson) { + this.partialJson = this.partialJson + partialJson; + return this; + } + + void squashIntoContentBlock() { + Map map = (StringUtils.hasText(this.partialJson)) + ? ModelOptionsUtils.jsonToMap(this.partialJson) : Map.of(); + this.toolContentBlocks.add(new ContentBlockStartEvent.ContentBlockToolUse("tool_use", this.id, this.name, map)); + this.index = null; + this.id = null; + this.name = null; + this.partialJson = ""; + } + + @Override + public String toString() { + return "EventToolUseBuilder [index=" + index + ", id=" + id + ", name=" + name + ", partialJson=" + + partialJson + ", toolUseMap=" + toolContentBlocks + "]"; + } + + } + + // CB START EVENT + @JsonInclude(Include.NON_NULL) - public record OutputUsage( // @formatter:off - @JsonProperty("output_tokens") Integer outputTokens) { - // @formatter:off - } + public record ContentBlockStartEvent(// @formatter:off + @JsonProperty("type") EventType type, + @JsonProperty("index") Integer index, + @JsonProperty("content_block") ContentBlockBody contentBlock) implements StreamEvent { - /** - * The role of the author of this message. - */ - public enum Role { // @formatter:off - @JsonProperty("user") USER, - @JsonProperty("assistant") ASSISTANT - // @formatter:on + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", + visible = true) + @JsonSubTypes({ @JsonSubTypes.Type(value = ContentBlockToolUse.class, name = "tool_use"), + @JsonSubTypes.Type(value = ContentBlockText.class, name = "text") }) + public interface ContentBlockBody { + String type(); + } - } + @JsonInclude(Include.NON_NULL) + public record ContentBlockToolUse( + @JsonProperty("type") String type, + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("input") Map input) implements ContentBlockBody { + } + + @JsonInclude(Include.NON_NULL) + public record ContentBlockText( + @JsonProperty("type") String type, + @JsonProperty("text") String text) implements ContentBlockBody { + } + }// @formatter:on + + // CB DELTA EVENT - /** - * Streaming chat completion response. Provides partial information for either the - * ResponseMessage or its MediaContent. The event type defines what partial - * information is provided. - * - * @param type The server event type of the stream response. Each stream uses the - * following event flow: (1) 'message_start': contains a Message object with empty - * content; (2) A series of content blocks, each of which have a - * 'content_block_start', one or more 'content_block_delta events', and a - * 'content_block_stop' event. Each content block will have an 'index' that - * corresponds to its index in the final Message content array. - * @param index The index of the content block. Applicable only for "content_block" - * type. - * @param message The message object. Applicable only for "message_start" type. - * @param contentBlock The content block object. Applicable only for "content_block" - * type. - * @param delta The delta object. Applicable only for "content_block_delta" and - * "message_delta" types. - * - */ @JsonInclude(Include.NON_NULL) - public record StreamResponse( // @formatter:off - @JsonProperty("type") String type, - @JsonProperty("index") Integer index, - @JsonProperty("message") ChatCompletion message, - @JsonProperty("content_block") MediaContent contentBlock, - @JsonProperty("delta") Map delta, - @JsonProperty("usage") OutputUsage usage, - List mergedToolUses) { - // @formatter:on - } + public record ContentBlockDeltaEvent(// @formatter:off + @JsonProperty("type") EventType type, + @JsonProperty("index") Integer index, + @JsonProperty("delta") ContentBlockDeltaBody delta) implements StreamEvent { + + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", + visible = true) + @JsonSubTypes({ @JsonSubTypes.Type(value = ContentBlockDeltaText.class, name = "text_delta"), + @JsonSubTypes.Type(value = ContentBlockDeltaJson.class, name = "input_json_delta") }) + public interface ContentBlockDeltaBody { + String type(); + } + + @JsonInclude(Include.NON_NULL) + public record ContentBlockDeltaText( + @JsonProperty("type") String type, + @JsonProperty("text") String text) implements ContentBlockDeltaBody { + } + + @JsonInclude(Include.NON_NULL) + public record ContentBlockDeltaJson( + @JsonProperty("type") String type, + @JsonProperty("partial_json") String partialJson) implements ContentBlockDeltaBody { + } + }// @formatter:on + + /// ECB STOP + + @JsonInclude(Include.NON_NULL) + public record ContentBlockStopEvent(// @formatter:off + @JsonProperty("type") EventType type, + @JsonProperty("index") Integer index) implements StreamEvent { + }// @formatter:on + + /////////////////////////////////////// + /// MESSAGE EVENTS + /////////////////////////////////////// + + // MESSAGE START EVENT + + @JsonInclude(Include.NON_NULL) + public record MessageStartEvent(// @formatter:off + @JsonProperty("type") EventType type, + @JsonProperty("message") ChatCompletionResponse message) implements StreamEvent { + }// @formatter:on + + // MESSAGE DELTA EVENT + + @JsonInclude(Include.NON_NULL) + public record MessageDeltaEvent(// @formatter:off + @JsonProperty("type") EventType type, + @JsonProperty("delta") MessageDelta delta, + @JsonProperty("usage") MessageDeltaUsage usage) implements StreamEvent { + + @JsonInclude(Include.NON_NULL) + public record MessageDelta( + @JsonProperty("stop_reason") String stopReason, + @JsonProperty("stop_sequence") String stopSequence) { + } + + @JsonInclude(Include.NON_NULL) + public record MessageDeltaUsage( + @JsonProperty("output_tokens") Integer outputTokens) { + } + }// @formatter:on + + // MESSAGE STOP EVENT + + @JsonInclude(Include.NON_NULL) + public record MessageStopEvent(// @formatter:off + @JsonProperty("type") EventType type) implements StreamEvent { + }// @formatter:on + + /////////////////////////////////////// + /// ERROR EVENT + /////////////////////////////////////// + @JsonInclude(Include.NON_NULL) + public record ErrorEvent(// @formatter:off + @JsonProperty("type") EventType type, + @JsonProperty("error") Error error) implements StreamEvent { + + @JsonInclude(Include.NON_NULL) + public record Error( + @JsonProperty("type") String type, + @JsonProperty("message") String message) { + } + }// @formatter:on + + /////////////////////////////////////// + /// PING EVENT + /////////////////////////////////////// + @JsonInclude(Include.NON_NULL) + public record PingEvent(// @formatter:off + @JsonProperty("type") EventType type) implements StreamEvent { + }// @formatter:on /** * Creates a model response for the given chat conversation. * @param chatRequest The chat completion request. - * @return Entity response with {@link ChatCompletion} as a body and HTTP status code - * and headers. + * @return Entity response with {@link ChatCompletionResponse} as a body and HTTP + * status code and headers. */ - public ResponseEntity chatCompletionEntity(ChatCompletionRequest chatRequest) { + public ResponseEntity chatCompletionEntity(ChatCompletionRequest chatRequest) { Assert.notNull(chatRequest, "The request body can not be null."); Assert.isTrue(!chatRequest.stream(), "Request must set the steam property to false."); - return this.restClient.post().uri("/v1/messages").body(chatRequest).retrieve().toEntity(ChatCompletion.class); + return this.restClient.post() + .uri("/v1/messages") + .body(chatRequest) + .retrieve() + .toEntity(ChatCompletionResponse.class); } - private final AnthropicStreamFunctionCallingHelper chunkMerger = new AnthropicStreamFunctionCallingHelper(); + private final StreamHelper streamHelper = new StreamHelper(); /** * Creates a streaming chat response for the given chat conversation. @@ -604,13 +868,15 @@ public class AnthropicApi { * to true. * @return Returns a {@link Flux} stream from chat completion chunks. */ - public Flux chatCompletionStream(ChatCompletionRequest chatRequest) { + public Flux chatCompletionStream(ChatCompletionRequest chatRequest) { Assert.notNull(chatRequest, "The request body can not be null."); Assert.isTrue(chatRequest.stream(), "Request must set the steam property to true."); AtomicBoolean isInsideTool = new AtomicBoolean(false); + AtomicReference chatCompletionReference = new AtomicReference<>(); + return this.webClient.post() .uri("/v1/messages") .body(Mono.just(chatRequest), ChatCompletionRequest.class) @@ -618,17 +884,17 @@ public class AnthropicApi { .bodyToFlux(String.class) .takeUntil(SSE_DONE_PREDICATE) .filter(SSE_DONE_PREDICATE.negate()) - .map(content -> ModelOptionsUtils.jsonToObject(content, StreamResponse.class)) + .map(content -> ModelOptionsUtils.jsonToObject(content, StreamEvent.class)) // Detect if the chunk is part of a streaming function call. - .map(chunk -> { - if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { + .map(event -> { + if (this.streamHelper.isToolUseStart(event)) { isInsideTool.set(true); } - return chunk; + return event; }) // Group all chunks belonging to the same function call. - .windowUntil(chunk -> { - if (isInsideTool.get() && this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { + .windowUntil(event -> { + if (isInsideTool.get() && this.streamHelper.isToolUseFinish(event)) { isInsideTool.set(false); return true; } @@ -636,12 +902,13 @@ public class AnthropicApi { }) // Merging the window chunks into a single chunk. .concatMapIterable(window -> { - Mono monoChunk = window.reduce(this.chunkMerger.emptyChunk(), - this.chunkMerger::mergeChunks); + Mono monoChunk = window.reduce(new ToolUseAggregationEvent(), + this.streamHelper::mergeToolUseEvents); return List.of(monoChunk); }) - // Flux> -> Flux - .flatMap(mono -> mono); + .flatMap(mono -> mono) + .map(event -> streamHelper.eventToChatCompletionResponse(event, chatCompletionReference)) + .filter(chatCompletionResponse -> chatCompletionResponse.type() != null); } } diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicStreamFunctionCallingHelper.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicStreamFunctionCallingHelper.java deleted file mode 100644 index a9700ea0a..000000000 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicStreamFunctionCallingHelper.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2023 - 2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.ai.anthropic.api; - -import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent; -import org.springframework.ai.anthropic.api.AnthropicApi.StreamResponse; -import org.springframework.ai.model.ModelOptionsUtils; - -import java.util.ArrayList; -import java.util.List; - -/** - * Helper class to support streaming function calling. - *

- * It can merge the streamed {@link StreamResponse} chunks in case of function calling - * message. - * - * @author Mariusz Bernacki - * @since 1.0.0 - */ -public class AnthropicStreamFunctionCallingHelper { - - public boolean isStreamingToolFunctionCall(StreamResponse response) { - if (response == null || response.contentBlock() == null) { - return false; - } - - return response.contentBlock().type() == MediaContent.Type.TOOL_USE; - } - - public boolean isStreamingToolFunctionCallFinish(StreamResponse response) { - return response.delta() != null && "tool_use".equals(response.delta().get("stop_reason")); - } - - public StreamResponse emptyChunk() { - return new StreamResponse(null, null, null, null, null, null, new ArrayList<>()); - } - - public StreamResponse mergeChunks(StreamResponse previous, StreamResponse current) { - if (previous == null) { - return current; - } - if (current == null) { - return previous; - } - - List mergedContent = mergeToolUses(previous, current); - - if (isStreamingToolFunctionCallFinish(current)) { - finalizeToolUsesAggregation(mergedContent); - } - - return new StreamResponse(lastElement(previous.type(), current.type()), - lastElement(previous.index(), current.index()), lastElement(previous.message(), current.message()), - lastElement(previous.contentBlock(), current.contentBlock()), - lastElement(previous.delta(), current.delta()), lastElement(previous.usage(), current.usage()), - mergedContent); - } - - private List mergeToolUses(StreamResponse previous, StreamResponse current) { - List mergedContent = new ArrayList<>(previous.mergedToolUses()); - - if (current.contentBlock() != null) { - mergedContent.add(current.contentBlock()); - } - else if (!mergedContent.isEmpty() && current.delta() != null && current.delta().containsKey("partial_json")) { - int lastIndex = mergedContent.size() - 1; - MediaContent previousMedia = mergedContent.get(lastIndex); - MediaContent currentMedia = new MediaContent(previousMedia.type(), previousMedia.index(), - previousMedia.id(), previousMedia.name(), - concat(previousMedia.inputJson(), (String) current.delta().get("partial_json"))); - - mergedContent.set(lastIndex, currentMedia); - } - - return mergedContent; - } - - public void finalizeToolUsesAggregation(List mergedToolUses) { - mergedToolUses.replaceAll(media -> { - if (media.inputJson() == null) { - return media; - } - - return new MediaContent(media.type(), media.source(), media.text(), null, media.id(), media.name(), - ModelOptionsUtils.jsonToMap(media.inputJson()), null, media.toolUseId(), media.content()); - }); - } - - private static T lastElement(T left, T right) { - return right != null ? right : left; - } - - private static String concat(String left, String right) { - if (left == null) { - return right; - } - if (right == null) { - return left; - } - - return left + right; - } - -} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java new file mode 100644 index 000000000..a699b0638 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/StreamHelper.java @@ -0,0 +1,262 @@ +/* + * Copyright 2023 - 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.ai.anthropic.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock.ContentBlockType; +import org.springframework.ai.anthropic.api.AnthropicApi.Role; +import org.springframework.ai.anthropic.api.AnthropicApi.Usage; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlockDeltaEvent; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlockStartEvent; +import org.springframework.ai.anthropic.api.AnthropicApi.ToolUseAggregationEvent; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.ai.anthropic.api.AnthropicApi.MessageDeltaEvent; +import org.springframework.ai.anthropic.api.AnthropicApi.MessageStartEvent; +import org.springframework.ai.anthropic.api.AnthropicApi.StreamEvent; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlockDeltaEvent.ContentBlockDeltaJson; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlockDeltaEvent.ContentBlockDeltaText; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlockStartEvent.ContentBlockText; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlockStartEvent.ContentBlockToolUse; +import org.springframework.ai.anthropic.api.AnthropicApi.EventType; + +/** + * Helper class to support streaming function calling. + *

+ * It can merge the streamed {@link StreamEvent} chunks in case of function calling + * message. + * + * @author Mariusz Bernacki + * @author Christian Tzolov + * @since 1.0.0 + */ +public class StreamHelper { + + public boolean isToolUseStart(StreamEvent event) { + if (event == null || event.type() == null || event.type() != EventType.CONTENT_BLOCK_START) { + return false; + } + return "tool_use".equals(((ContentBlockStartEvent) event).contentBlock().type()); + } + + public boolean isToolUseFinish(StreamEvent event) { + + if (event == null || event.type() == null || event.type() != EventType.MESSAGE_DELTA) { + return false; + } + return "tool_use".equals(((MessageDeltaEvent) event).delta().stopReason()); + } + + public StreamEvent mergeToolUseEvents(StreamEvent previousEvent, StreamEvent event) { + + ToolUseAggregationEvent eventAggregator = (ToolUseAggregationEvent) previousEvent; + + if (event.type() == EventType.CONTENT_BLOCK_START) { + ContentBlockStartEvent contentBlockStart = (ContentBlockStartEvent) event; + + if ("tool_use".equals(contentBlockStart.contentBlock().type())) { + ContentBlockStartEvent.ContentBlockToolUse cbToolUse = (ContentBlockToolUse) contentBlockStart + .contentBlock(); + + return eventAggregator.withIndex(contentBlockStart.index()) + .withId(cbToolUse.id()) + .withName(cbToolUse.name()) + .appendPartialJson(""); // CB START always has empty JSON. + } + } + else if (event.type() == EventType.CONTENT_BLOCK_DELTA) { + ContentBlockDeltaEvent contentBolckDelta = (ContentBlockDeltaEvent) event; + if ("input_json_delta".equals(contentBolckDelta.delta().type())) { + return eventAggregator + .appendPartialJson(((ContentBlockDeltaJson) contentBolckDelta.delta()).partialJson()); + } + } + else if (event.type() == EventType.CONTENT_BLOCK_STOP) { + if (!eventAggregator.isEmpty()) { + eventAggregator.squashIntoContentBlock(); + return eventAggregator; + } + } + else if (isToolUseFinish(event)) { + return eventAggregator; + } + + return event; + } + + public ChatCompletionResponse eventToChatCompletionResponse(StreamEvent event, + AtomicReference contentBlockReference) { + + // https://docs.anthropic.com/claude/reference/messages-streaming + + if (event.type().equals(EventType.MESSAGE_START)) { + contentBlockReference.set(new ChatCompletionResponseBuilder()); + + MessageStartEvent messageStartEvent = (MessageStartEvent) event; + + contentBlockReference.get() + .withType(event.type().name()) + .withId(messageStartEvent.message().id()) + .withRole(messageStartEvent.message().role()) + .withModel(messageStartEvent.message().model()) + .withUsage(messageStartEvent.message().usage()) + .withContent(new ArrayList<>()); + } + else if (event.type().equals(EventType.TOOL_USE_AGGREATE)) { + ToolUseAggregationEvent eventToolUseBuilder = (ToolUseAggregationEvent) event; + + if (!CollectionUtils.isEmpty(eventToolUseBuilder.getToolContentBlocks())) { + + List content = eventToolUseBuilder.getToolContentBlocks() + .stream() + .map(tooToUse -> new ContentBlock(ContentBlockType.TOOL_USE, tooToUse.id(), tooToUse.name(), + tooToUse.input())) + .toList(); + contentBlockReference.get().withContent(content); + } + } + else if (event.type().equals(EventType.CONTENT_BLOCK_START)) { + ContentBlockStartEvent contentBlockStartEvent = (ContentBlockStartEvent) event; + + Assert.isTrue(contentBlockStartEvent.contentBlock().type().equals("text"), + "The json content block should have been aggregated. Unsupported content block type: " + + contentBlockStartEvent.contentBlock().type()); + + ContentBlockText contentBlockText = (ContentBlockText) contentBlockStartEvent.contentBlock(); + ContentBlock contentBlock = new ContentBlock(ContentBlockType.TEXT, null, contentBlockText.text(), + contentBlockStartEvent.index()); + contentBlockReference.get().withType(event.type().name()).withContent(List.of(contentBlock)); + } + else if (event.type().equals(EventType.CONTENT_BLOCK_DELTA)) { + + ContentBlockDeltaEvent contentBlockDeltaEvent = (ContentBlockDeltaEvent) event; + + Assert.isTrue(contentBlockDeltaEvent.delta().type().equals("text_delta"), + "The json content block delta should have been aggregated. Unsupported content block type: " + + contentBlockDeltaEvent.delta().type()); + + ContentBlockDeltaText deltaTxt = (ContentBlockDeltaText) contentBlockDeltaEvent.delta(); + + var contentBlock = new ContentBlock(ContentBlockType.TEXT_DELTA, null, deltaTxt.text(), + contentBlockDeltaEvent.index()); + + contentBlockReference.get().withType(event.type().name()).withContent(List.of(contentBlock)); + } + else if (event.type().equals(EventType.MESSAGE_DELTA)) { + + contentBlockReference.get().withType(event.type().name()); + + MessageDeltaEvent messageDeltaEvent = (MessageDeltaEvent) event; + + if (StringUtils.hasText(messageDeltaEvent.delta().stopReason())) { + contentBlockReference.get().withStopReason(messageDeltaEvent.delta().stopReason()); + } + + if (StringUtils.hasText(messageDeltaEvent.delta().stopSequence())) { + contentBlockReference.get().withStopSequence(messageDeltaEvent.delta().stopSequence()); + } + + if (messageDeltaEvent.usage() != null) { + var totalUsage = new Usage(contentBlockReference.get().usage.inputTokens(), + messageDeltaEvent.usage().outputTokens()); + contentBlockReference.get().withUsage(totalUsage); + } + } + else if (event.type().equals(EventType.MESSAGE_STOP)) { + + } + else { + contentBlockReference.get().withType(event.type().name()).withContent(List.of()); + } + + return contentBlockReference.get().build(); + } + + public static class ChatCompletionResponseBuilder { + + private String type; + + private String id; + + private Role role; + + private List content; + + private String model; + + private String stopReason; + + private String stopSequence; + + private Usage usage; + + public ChatCompletionResponseBuilder() { + } + + public ChatCompletionResponseBuilder withType(String type) { + this.type = type; + return this; + } + + public ChatCompletionResponseBuilder withId(String id) { + this.id = id; + return this; + } + + public ChatCompletionResponseBuilder withRole(Role role) { + this.role = role; + return this; + } + + public ChatCompletionResponseBuilder withContent(List content) { + this.content = content; + return this; + } + + public ChatCompletionResponseBuilder withModel(String model) { + this.model = model; + return this; + } + + public ChatCompletionResponseBuilder withStopReason(String stopReason) { + this.stopReason = stopReason; + return this; + } + + public ChatCompletionResponseBuilder withStopSequence(String stopSequence) { + this.stopSequence = stopSequence; + return this; + } + + public ChatCompletionResponseBuilder withUsage(Usage usage) { + this.usage = usage; + return this; + } + + public ChatCompletionResponse build() { + return new ChatCompletionResponse(this.id, this.type, this.role, this.content, this.model, this.stopReason, + this.stopSequence, this.usage); + } + + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/metadata/AnthropicChatResponseMetadata.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/metadata/AnthropicChatResponseMetadata.java index 9c458bd3a..0ce5d8b90 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/metadata/AnthropicChatResponseMetadata.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/metadata/AnthropicChatResponseMetadata.java @@ -40,7 +40,7 @@ public class AnthropicChatResponseMetadata extends HashMap imple protected static final String AI_METADATA_STRING = "{ @type: %1$s, id: %2$s, model: %3$s, usage: %4$s, rateLimit: %5$s }"; - public static AnthropicChatResponseMetadata from(AnthropicApi.ChatCompletion result) { + public static AnthropicChatResponseMetadata from(AnthropicApi.ChatCompletionResponse result) { Assert.notNull(result, "Anthropic ChatCompletionResult must not be null"); AnthropicUsage usage = AnthropicUsage.from(result.usage()); return new AnthropicChatResponseMetadata(result.id(), result.model(), usage); diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelIT.java index c775681ac..130d42a6e 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelIT.java @@ -15,6 +15,8 @@ */ package org.springframework.ai.anthropic; +import static org.assertj.core.api.Assertions.assertThat; + import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -28,18 +30,17 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.anthropic.api.tool.MockWeatherService; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.chat.model.StreamingChatModel; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Media; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.model.StreamingChatModel; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.chat.prompt.SystemPromptTemplate; @@ -49,15 +50,18 @@ import org.springframework.ai.converter.MapOutputConverter; import org.springframework.ai.model.function.FunctionCallbackWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.util.MimeTypeUtils; +import org.springframework.util.StringUtils; -import static org.assertj.core.api.Assertions.assertThat; +import reactor.core.publisher.Flux; -@SpringBootTest(classes = AnthropicTestConfiguration.class, properties = "spring.ai.retry.on-http-codes=429") +@SpringBootTest(classes = AnthropicChatModelIT.Config.class, properties = "spring.ai.retry.on-http-codes=429") @EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+") class AnthropicChatModelIT { @@ -226,7 +230,7 @@ class AnthropicChatModelIT { List messages = new ArrayList<>(List.of(userMessage)); var promptOptions = AnthropicChatOptions.builder() - .withModel(AnthropicApi.ChatModel.CLAUDE_3_OPUS) + .withModel(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getModelName()) .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) .withName("getCurrentWeather") .withDescription( @@ -242,6 +246,38 @@ class AnthropicChatModelIT { assertThat(generation.getOutput().getContent()).contains("30", "10", "15"); } + @Test + void streamFunctionCallTest() { + + UserMessage userMessage = new UserMessage( + // "What's the weather like in San Francisco? Return the result in + // Celsius."); + "What's the weather like in San Francisco, Tokyo and Paris? Return the result in Celsius."); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = AnthropicChatOptions.builder() + .withModel(AnthropicApi.ChatModel.CLAUDE_3_5_SONNET.getModelName()) + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName("getCurrentWeather") + .withDescription( + "Get the weather in location. Return temperature in 36°F or 36°C format. Use multi-turn if needed.") + .build())) + .build(); + + Flux response = chatModel.stream(new Prompt(messages, promptOptions)); + + String content = response.collectList() + .block() + .stream() + .filter(cr -> cr.getResult() != null) + .map(cr -> cr.getResult().getOutput().getContent()) + .collect(Collectors.joining()); + + logger.info("Response: {}", content); + assertThat(content).contains("30", "10", "15"); + } + @Test void validateCallResponseMetadata() { String model = AnthropicApi.ChatModel.CLAUDE_2_1.getModelName(); @@ -281,4 +317,28 @@ class AnthropicChatModelIT { assertThat(response.getMetadata().getUsage().getTotalTokens()).isPositive(); } + @SpringBootConfiguration + public static class Config { + + @Bean + public AnthropicApi anthropicApi() { + return new AnthropicApi(getApiKey()); + } + + private String getApiKey() { + String apiKey = System.getenv("ANTHROPIC_API_KEY"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "You must provide an API key. Put it in an environment variable under the name ANTHROPIC_API_KEY"); + } + return apiKey; + } + + @Bean + public AnthropicChatModel openAiChatModel(AnthropicApi api) { + return new AnthropicChatModel(api); + } + + } + } \ No newline at end of file diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/EventParsingTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/EventParsingTests.java new file mode 100644 index 000000000..d57bf765b --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/EventParsingTests.java @@ -0,0 +1,55 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package org.springframework.ai.anthropic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.anthropic.api.AnthropicApi.StreamEvent; +import org.springframework.core.io.DefaultResourceLoader; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public class EventParsingTests { + + private static final Logger logger = LoggerFactory.getLogger(EventParsingTests.class); + + @Test + public void readEvents() throws IOException { + String json = new DefaultResourceLoader().getResource("classpath:/sample_events.json") + .getContentAsString(Charset.defaultCharset()); + + List events = new ObjectMapper().readerFor(new TypeReference>() { + }).readValue(json); + + logger.info(events.toString()); + + assertThat(events).hasSize(31); + + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java index 696b9c644..013b315ed 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiIT.java @@ -15,21 +15,20 @@ */ package org.springframework.ai.anthropic.api; +import static org.assertj.core.api.Assertions.assertThat; + import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import reactor.core.publisher.Flux; - -import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletion; -import org.springframework.ai.anthropic.api.AnthropicApi.RequestMessage; +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicMessage; import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest; -import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent; +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock; import org.springframework.ai.anthropic.api.AnthropicApi.Role; -import org.springframework.ai.anthropic.api.AnthropicApi.StreamResponse; import org.springframework.http.ResponseEntity; -import static org.assertj.core.api.Assertions.assertThat; +import reactor.core.publisher.Flux; /** * @author Christian Tzolov @@ -42,9 +41,9 @@ public class AnthropicApiIT { @Test void chatCompletionEntity() { - RequestMessage chatCompletionMessage = new RequestMessage(List.of(new MediaContent("Tell me a Joke?")), + AnthropicMessage chatCompletionMessage = new AnthropicMessage(List.of(new ContentBlock("Tell me a Joke?")), Role.USER); - ResponseEntity response = anthropicApi + ResponseEntity response = anthropicApi .chatCompletionEntity(new ChatCompletionRequest(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(), List.of(chatCompletionMessage), null, 100, 0.8f, false)); @@ -56,16 +55,16 @@ public class AnthropicApiIT { @Test void chatCompletionStream() { - RequestMessage chatCompletionMessage = new RequestMessage(List.of(new MediaContent("Tell me a Joke?")), + AnthropicMessage chatCompletionMessage = new AnthropicMessage(List.of(new ContentBlock("Tell me a Joke?")), Role.USER); - Flux response = anthropicApi + Flux response = anthropicApi .chatCompletionStream(new ChatCompletionRequest(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(), List.of(chatCompletionMessage), null, 100, 0.8f, true)); assertThat(response).isNotNull(); - List bla = response.collectList().block(); + List bla = response.collectList().block(); assertThat(bla).isNotNull(); bla.stream().forEach(r -> System.out.println(r)); diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/tool/AnthropicApiLegacyToolIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/tool/AnthropicApiLegacyToolIT.java index d338428cb..652a400f6 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/tool/AnthropicApiLegacyToolIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/tool/AnthropicApiLegacyToolIT.java @@ -25,10 +25,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.anthropic.api.AnthropicApi; -import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletion; +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse; import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest; -import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent; -import org.springframework.ai.anthropic.api.AnthropicApi.RequestMessage; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock; +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicMessage; import org.springframework.ai.anthropic.api.AnthropicApi.Role; import org.springframework.ai.anthropic.api.tool.XmlHelper.FunctionCalls; import org.springframework.ai.anthropic.api.tool.XmlHelper.Tools; @@ -100,8 +100,8 @@ public class AnthropicApiLegacyToolIT { String systemPrompt = String.format(TOO_SYSTEM_PROMPT_TEMPLATE, toolDescription); - RequestMessage chatCompletionMessage = new RequestMessage( - List.of(new MediaContent("What's the weather like in Paris? Show the temperature in Celsius.")), + AnthropicMessage chatCompletionMessage = new AnthropicMessage( + List.of(new ContentBlock("What's the weather like in Paris? Show the temperature in Celsius.")), // "What's the weather like in San Francisco, Tokyo, and Paris? Show the // temperature in Celsius.")), Role.USER); @@ -110,7 +110,7 @@ public class AnthropicApiLegacyToolIT { AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(), List.of(chatCompletionMessage), systemPrompt, 500, 0.8f, false); - ResponseEntity chatCompletion = doCall(chatCompletionRequest); + ResponseEntity chatCompletion = doCall(chatCompletionRequest); var responseText = chatCompletion.getBody().content().get(0).text(); logger.info("FINAL RESPONSE: " + responseText); @@ -118,9 +118,9 @@ public class AnthropicApiLegacyToolIT { assertThat(responseText).contains("15"); } - private ResponseEntity doCall(ChatCompletionRequest chatCompletionRequest) { + private ResponseEntity doCall(ChatCompletionRequest chatCompletionRequest) { - ResponseEntity response = anthropicApi.chatCompletionEntity(chatCompletionRequest); + ResponseEntity response = anthropicApi.chatCompletionEntity(chatCompletionRequest); FunctionCalls functionCalls = XmlHelper.extractFunctionCalls(response.getBody().content().get(0).text()); @@ -144,7 +144,7 @@ public class AnthropicApiLegacyToolIT { logger.info("Function response XML : " + content); - RequestMessage chatCompletionMessage2 = new RequestMessage(List.of(new MediaContent(content)), Role.USER); + AnthropicMessage chatCompletionMessage2 = new AnthropicMessage(List.of(new ContentBlock(content)), Role.USER); return doCall(new ChatCompletionRequest(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(), List.of(chatCompletionMessage2), null, 500, 0.8f, false)); diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/tool/AnthropicApiToolIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/tool/AnthropicApiToolIT.java index 75276a1f9..c447c6a29 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/tool/AnthropicApiToolIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/tool/AnthropicApiToolIT.java @@ -26,11 +26,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.anthropic.api.AnthropicApi; -import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletion; +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse; import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest; -import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent; -import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent.Type; -import org.springframework.ai.anthropic.api.AnthropicApi.RequestMessage; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock; +import org.springframework.ai.anthropic.api.AnthropicApi.ContentBlock.ContentBlockType; +import org.springframework.ai.anthropic.api.AnthropicApi.AnthropicMessage; import org.springframework.ai.anthropic.api.AnthropicApi.Role; import org.springframework.ai.anthropic.api.AnthropicApi.Tool; import org.springframework.ai.model.ModelOptionsUtils; @@ -84,15 +84,15 @@ public class AnthropicApiToolIT { @Test void toolCalls() { - List messageConversation = new ArrayList<>(); + List messageConversation = new ArrayList<>(); - RequestMessage chatCompletionMessage = new RequestMessage(List.of(new MediaContent( + AnthropicMessage chatCompletionMessage = new AnthropicMessage(List.of(new ContentBlock( "What's the weather like in San Francisco, Tokyo, and Paris? Show the temperature in Celsius.")), Role.USER); messageConversation.add(chatCompletionMessage); - ResponseEntity chatCompletion = doCall(messageConversation); + ResponseEntity chatCompletion = doCall(messageConversation); var responseText = chatCompletion.getBody().content().get(0).text(); logger.info("FINAL RESPONSE: " + responseText); @@ -102,7 +102,7 @@ public class AnthropicApiToolIT { assertThat(responseText).contains("30"); } - private ResponseEntity doCall(List messageConversation) { + private ResponseEntity doCall(List messageConversation) { ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() .withModel(AnthropicApi.ChatModel.CLAUDE_3_OPUS) @@ -112,23 +112,23 @@ public class AnthropicApiToolIT { .withTools(tools) .build(); - ResponseEntity response = anthropicApi.chatCompletionEntity(chatCompletionRequest); + ResponseEntity response = anthropicApi.chatCompletionEntity(chatCompletionRequest); - List toolToUseList = response.getBody() + List toolToUseList = response.getBody() .content() .stream() - .filter(c -> c.type() == MediaContent.Type.TOOL_USE) + .filter(c -> c.type() == ContentBlock.ContentBlockType.TOOL_USE) .toList(); if (CollectionUtils.isEmpty(toolToUseList)) { return response; } // Add use tool message to the conversation history - messageConversation.add(new RequestMessage(response.getBody().content(), Role.ASSISTANT)); + messageConversation.add(new AnthropicMessage(response.getBody().content(), Role.ASSISTANT)); - List toolResults = new ArrayList<>(); + List toolResults = new ArrayList<>(); - for (MediaContent toolToUse : toolToUseList) { + for (ContentBlock toolToUse : toolToUseList) { var id = toolToUse.id(); var name = toolToUse.name(); @@ -146,11 +146,11 @@ public class AnthropicApiToolIT { logger.info("Function response : " + content); - toolResults.add(new MediaContent(Type.TOOL_RESULT, id, content)); + toolResults.add(new ContentBlock(ContentBlockType.TOOL_RESULT, id, content)); } // Add function response message to the conversation history - messageConversation.add(new RequestMessage(toolResults, Role.USER)); + messageConversation.add(new AnthropicMessage(toolResults, Role.USER)); return doCall(messageConversation); } diff --git a/models/spring-ai-anthropic/src/test/resources/sample_events.json b/models/spring-ai-anthropic/src/test/resources/sample_events.json new file mode 100644 index 000000000..d5a5b4725 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/resources/sample_events.json @@ -0,0 +1,243 @@ +[ + { + "type": "message_start", + "message": { + "id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", + "type": "message", + "role": "assistant", + "content": [], + "model": "claude-3-5-sonnet-20240620", + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 1 + } + } + }, + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "text", + "text": "" + } + }, + { + "type": "ping" + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": "Okay" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": "," + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": " let" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": "'s" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": " check" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": " the" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": " weather" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": " for" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": " San" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": " Francisco" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": "," + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": " CA" + } + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": ":" + } + }, + { + "type": "content_block_stop", + "index": 0 + }, + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_01T1x1fJ34qAmk2tNTrN7Up6", + "name": "get_weather", + "input": {} + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": "" + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": "{\"location\":" + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": " \"San" + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": " Francisc" + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": "o," + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": " CA\"" + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": ", " + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": "\"unit\": \"fah" + } + }, + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": "renheit\"}" + } + }, + { + "type": "content_block_stop", + "index": 1 + }, + { + "type": "message_delta", + "delta": { + "stop_reason": "end_turn", + "stop_sequence": null + }, + "usage": { + "output_tokens": 15 + } + }, + { + "type": "message_stop" + }, + { + "type": "error", + "error": { + "type": "overloaded_error", + "message": "Overloaded" + } + } +] \ No newline at end of file diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/anthropic-claude3-class-diagram.jpg b/spring-ai-docs/src/main/antora/modules/ROOT/images/anthropic-claude3-class-diagram.jpg index 06df55333..abd24804b 100644 Binary files a/spring-ai-docs/src/main/antora/modules/ROOT/images/anthropic-claude3-class-diagram.jpg and b/spring-ai-docs/src/main/antora/modules/ROOT/images/anthropic-claude3-class-diagram.jpg differ diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/anthropic-claude3-events-model.jpg b/spring-ai-docs/src/main/antora/modules/ROOT/images/anthropic-claude3-events-model.jpg new file mode 100644 index 000000000..3c03ac92f Binary files /dev/null and b/spring-ai-docs/src/main/antora/modules/ROOT/images/anthropic-claude3-events-model.jpg differ diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc index 94ce31592..00e4832b5 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc @@ -279,6 +279,8 @@ Following class diagram illustrates the `AnthropicApi` chat interfaces and build image::anthropic-claude3-class-diagram.jpg[AnthropicApi Chat API Diagram, width=1000, align="center"] +image::anthropic-claude3-events-model.jpg[AnthropicApi Event Model, width=1000, align="center"] + Here is a simple snippet how to use the api programmatically: [source,java] @@ -286,11 +288,11 @@ Here is a simple snippet how to use the api programmatically: AnthropicApi anthropicApi = new AnthropicApi(System.getenv("ANTHROPIC_API_KEY")); -RequestMessage chatCompletionMessage = new RequestMessage( - List.of(new MediaContent("Tell me a Joke?")), Role.USER); +AnthropicMessage chatCompletionMessage = new AnthropicMessage( + List.of(new ContentBlock("Tell me a Joke?")), Role.USER); // Sync request -ResponseEntity response = anthropicApi +ResponseEntity response = anthropicApi .chatCompletionEntity(new ChatCompletionRequest(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(), List.of(chatCompletionMessage), null, 100, 0.8f, false));