Improve Anthropic Claude streaming handling

- add StreamEven API domain model for reliably parsing stream events.
 - add StreamHelper#mergeToolUseEvents to aggregate partial tool use jsons into a list of ContentBlocks.
 - add StreamHelper#eventToChatCompletionResponse to convert Flux<StreamEvents> into Flux<ChatCompletionResponse>.
 - Rename MediaContent -> ContentBlock, RequestMessage -> AnthropicMessage, ChatCompletion -> ChatCompletionResponse.
 - Improve tests and docs.
This commit is contained in:
Christian Tzolov
2024-07-02 11:06:53 +02:00
parent cb6662a61e
commit d8be884cb1
14 changed files with 1065 additions and 445 deletions

View File

@@ -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<AnthropicApi.RequestMessage, AnthropicApi.ChatCompletionRequest, ResponseEntity<AnthropicApi.ChatCompletion>>
AbstractFunctionCallSupport<AnthropicApi.AnthropicMessage, AnthropicApi.ChatCompletionRequest, ResponseEntity<AnthropicApi.ChatCompletionResponse>>
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<ChatCompletion> completionEntity = this.callWithFunctionSupport(request);
ResponseEntity<ChatCompletionResponse> completionEntity = this.callWithFunctionSupport(request);
return toChatResponse(completionEntity.getBody());
});
}
@@ -163,87 +160,17 @@ public class AnthropicChatModel extends
return this.retryTemplate.execute(ctx -> {
Flux<StreamResponse> response = this.anthropicApi.chatCompletionStream(request);
Flux<ChatCompletionResponse> response = this.anthropicApi.chatCompletionStream(request);
AtomicReference<ChatCompletionBuilder> 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<ChatCompletionBuilder> 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<String> functionsForThisRequest = new HashSet<>();
List<RequestMessage> userMessages = prompt.getInstructions()
List<AnthropicMessage> userMessages = prompt.getInstructions()
.stream()
.filter(m -> m.getMessageType() != MessageType.SYSTEM)
.map(m -> {
List<MediaContent> contents = new ArrayList<>(List.of(new MediaContent(m.getContent())));
List<ContentBlock> contents = new ArrayList<>(List.of(new ContentBlock(m.getContent())));
if (!CollectionUtils.isEmpty(m.getMedia())) {
List<MediaContent> mediaContent = m.getMedia()
List<ContentBlock> 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<MediaContent> content;
private String model;
private String stopReason;
private String stopSequence;
private Usage usage;
private List<MediaContent> 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<MediaContent> 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<MediaContent> 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<RequestMessage> conversationHistory) {
AnthropicMessage responseMessage, List<AnthropicMessage> conversationHistory) {
List<MediaContent> toolToUseList = responseMessage.content()
List<ContentBlock> toolToUseList = responseMessage.content()
.stream()
.filter(c -> c.type() == MediaContent.Type.TOOL_USE)
.filter(c -> c.type() == ContentBlock.ContentBlockType.TOOL_USE)
.toList();
List<MediaContent> toolResults = new ArrayList<>();
List<ContentBlock> 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<RequestMessage> doGetUserMessages(ChatCompletionRequest request) {
protected List<AnthropicMessage> doGetUserMessages(ChatCompletionRequest request) {
return request.messages();
}
@Override
protected RequestMessage doGetToolResponseMessage(ResponseEntity<ChatCompletion> response) {
return new RequestMessage(response.getBody().content(), Role.ASSISTANT);
protected AnthropicMessage doGetToolResponseMessage(ResponseEntity<ChatCompletionResponse> response) {
return new AnthropicMessage(response.getBody().content(), Role.ASSISTANT);
}
@Override
protected ResponseEntity<ChatCompletion> doChatCompletion(ChatCompletionRequest request) {
protected ResponseEntity<ChatCompletionResponse> doChatCompletion(ChatCompletionRequest request) {
return this.anthropicApi.chatCompletionEntity(request);
}
@SuppressWarnings("null")
@Override
protected boolean isToolFunctionCall(ResponseEntity<ChatCompletion> response) {
protected boolean isToolFunctionCall(ResponseEntity<ChatCompletionResponse> 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<ResponseEntity<ChatCompletion>> doChatCompletionStream(ChatCompletionRequest request) {
protected Flux<ResponseEntity<ChatCompletionResponse>> doChatCompletionStream(ChatCompletionRequest request) {
AtomicReference<ChatCompletionBuilder> 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

View File

@@ -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
* <a href="https://docs.anthropic.com/claude/docs/models-overview">models</a> for
@@ -195,7 +214,7 @@ public class AnthropicApi {
@JsonInclude(Include.NON_NULL)
public record ChatCompletionRequest( // @formatter:off
@JsonProperty("model") String model,
@JsonProperty("messages") List<RequestMessage> messages,
@JsonProperty("messages") List<AnthropicMessage> messages,
@JsonProperty("system") String system,
@JsonProperty("max_tokens") Integer maxTokens,
@JsonProperty("metadata") Metadata metadata,
@@ -207,12 +226,12 @@ public class AnthropicApi {
@JsonProperty("tools") List<Tool> tools) {
// @formatter:on
public ChatCompletionRequest(String model, List<RequestMessage> messages, String system, Integer maxTokens,
public ChatCompletionRequest(String model, List<AnthropicMessage> 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<RequestMessage> messages, String system, Integer maxTokens,
public ChatCompletionRequest(String model, List<AnthropicMessage> messages, String system, Integer maxTokens,
List<String> 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<RequestMessage> messages;
private List<AnthropicMessage> messages;
private String system;
@@ -287,7 +306,7 @@ public class AnthropicApi {
return this;
}
public ChatCompletionRequestBuilder withMessages(List<RequestMessage> messages) {
public ChatCompletionRequestBuilder withMessages(List<AnthropicMessage> 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<MediaContent> content,
public record AnthropicMessage( // @formatter:off
@JsonProperty("content") List<ContentBlock> 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<String, Object> 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<String, Object> 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<MediaContent> content,
@JsonProperty("content") List<ContentBlock> 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<ContentBlockStartEvent.ContentBlockToolUse> toolContentBlocks = new ArrayList<>();
@Override
public EventType type() {
return EventType.TOOL_USE_AGGREATE;
}
public List<ContentBlockStartEvent.ContentBlockToolUse> 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<String, Object> 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<String, Object> 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<String, Object> delta,
@JsonProperty("usage") OutputUsage usage,
List<MediaContent> 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<ChatCompletion> chatCompletionEntity(ChatCompletionRequest chatRequest) {
public ResponseEntity<ChatCompletionResponse> 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<StreamResponse> chatCompletionStream(ChatCompletionRequest chatRequest) {
public Flux<ChatCompletionResponse> 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<ChatCompletionResponseBuilder> 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<StreamResponse> monoChunk = window.reduce(this.chunkMerger.emptyChunk(),
this.chunkMerger::mergeChunks);
Mono<StreamEvent> monoChunk = window.reduce(new ToolUseAggregationEvent(),
this.streamHelper::mergeToolUseEvents);
return List.of(monoChunk);
})
// Flux<Mono<StreamResponse>> -> Flux<StreamResponse>
.flatMap(mono -> mono);
.flatMap(mono -> mono)
.map(event -> streamHelper.eventToChatCompletionResponse(event, chatCompletionReference))
.filter(chatCompletionResponse -> chatCompletionResponse.type() != null);
}
}

View File

@@ -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.
* <p>
* 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<MediaContent> 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<MediaContent> mergeToolUses(StreamResponse previous, StreamResponse current) {
List<MediaContent> 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<MediaContent> 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> 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;
}
}

View File

@@ -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.
* <p>
* 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<ChatCompletionResponseBuilder> 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<ContentBlock> 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<ContentBlock> 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<ContentBlock> 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);
}
}
}

View File

@@ -40,7 +40,7 @@ public class AnthropicChatResponseMetadata extends HashMap<String, Object> 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);

View File

@@ -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<Message> 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<Message> 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<ChatResponse> 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);
}
}
}

View File

@@ -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<StreamEvent> events = new ObjectMapper().readerFor(new TypeReference<List<StreamEvent>>() {
}).readValue(json);
logger.info(events.toString());
assertThat(events).hasSize(31);
}
}

View File

@@ -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<ChatCompletion> response = anthropicApi
ResponseEntity<ChatCompletionResponse> 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<StreamResponse> response = anthropicApi
Flux<ChatCompletionResponse> response = anthropicApi
.chatCompletionStream(new ChatCompletionRequest(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(),
List.of(chatCompletionMessage), null, 100, 0.8f, true));
assertThat(response).isNotNull();
List<StreamResponse> bla = response.collectList().block();
List<ChatCompletionResponse> bla = response.collectList().block();
assertThat(bla).isNotNull();
bla.stream().forEach(r -> System.out.println(r));

View File

@@ -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> chatCompletion = doCall(chatCompletionRequest);
ResponseEntity<ChatCompletionResponse> 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<ChatCompletion> doCall(ChatCompletionRequest chatCompletionRequest) {
private ResponseEntity<ChatCompletionResponse> doCall(ChatCompletionRequest chatCompletionRequest) {
ResponseEntity<ChatCompletion> response = anthropicApi.chatCompletionEntity(chatCompletionRequest);
ResponseEntity<ChatCompletionResponse> 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));

View File

@@ -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<RequestMessage> messageConversation = new ArrayList<>();
List<AnthropicMessage> 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> chatCompletion = doCall(messageConversation);
ResponseEntity<ChatCompletionResponse> 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<ChatCompletion> doCall(List<RequestMessage> messageConversation) {
private ResponseEntity<ChatCompletionResponse> doCall(List<AnthropicMessage> messageConversation) {
ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder()
.withModel(AnthropicApi.ChatModel.CLAUDE_3_OPUS)
@@ -112,23 +112,23 @@ public class AnthropicApiToolIT {
.withTools(tools)
.build();
ResponseEntity<ChatCompletion> response = anthropicApi.chatCompletionEntity(chatCompletionRequest);
ResponseEntity<ChatCompletionResponse> response = anthropicApi.chatCompletionEntity(chatCompletionRequest);
List<MediaContent> toolToUseList = response.getBody()
List<ContentBlock> 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<MediaContent> toolResults = new ArrayList<>();
List<ContentBlock> 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);
}

View File

@@ -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"
}
}
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

View File

@@ -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<ChatCompletion> response = anthropicApi
ResponseEntity<ChatCompletionResponse> response = anthropicApi
.chatCompletionEntity(new ChatCompletionRequest(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(),
List.of(chatCompletionMessage), null, 100, 0.8f, false));