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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
243
models/spring-ai-anthropic/src/test/resources/sample_events.json
Normal file
243
models/spring-ai-anthropic/src/test/resources/sample_events.json
Normal 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 |
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user