Implement Anthropic Claude3 Message API client support (direct)

This commit introduces support for the Anthropic Claude3 Message API
  (https://api.anthropic.com), enabling direct interaction with its services.
  This is not a Bedrock Anthropic Claude3 implemenation.

  Changes include:

  - Implementation of a low-level client, AnthropicApi, to interact with
    the message API endpoints specified in the Anthropic documentation
    (https://docs.anthropic.com/claude/reference/messages_post), including support for streaming.
  - Addition of AnthropicApi tests to ensure functionality and reliability.
  - Support for multimodal requests within AnthropicApi.
  - Adding the spring-ai-anthropic and boot starter into BOM and parent POM modules for streamlined usage.
  - Add Anthropic Auto-configuration and Boot Starter for seamless integration into existing projects.
  - Implementation of AnthropicChatClient with capabilities for synchronous and streaming communication,
    including support for multimodal messages.
  - Inclusion of both unit and integration tests to validate functionality across various scenarios.
  - Add Antora documentation with comprehensive guidance on using AnthropicApi and AnthropicChatClient.
  - Add of Ahead-of-Time (AOT) hints for AnthropicApi.
  - update anthropic diagram
This commit is contained in:
Christian Tzolov
2024-03-15 11:55:27 +01:00
committed by Mark Pollack
parent 8503078088
commit ce2bb131e5
32 changed files with 2441 additions and 14 deletions

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-anthropic</artifactId>
<packaging>jar</packaging>
<name>Spring AI Anthropic Chat Client</name>
<description>Anthropic support</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<dependencies>
<!-- production dependencies -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-retry</artifactId>
<version>${project.parent.version}</version>
</dependency>
<!-- NOTE: Required only by the @ConstructorBinding. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-path</artifactId>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
<version>${victools.version}</version>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jackson</artifactId>
<version>${victools.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-test</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,344 @@
/*
* 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;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletion;
import org.springframework.ai.anthropic.api.AnthropicApi.RequestMessage;
import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent;
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest;
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.ChatClient;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.chat.StreamingChatClient;
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;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
/**
* The {@link ChatClient} implementation for the Anthropic service.
*
* @author Christian Tzolov
* @since 1.0.0
*/
public class AnthropicChatClient implements ChatClient, StreamingChatClient {
private static final Logger logger = LoggerFactory.getLogger(AnthropicChatClient.class);
public static final String DEFAULT_MODEL_NAME = AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue();
public static final Integer DEFAULT_MAX_TOKENS = 500;
public static final Float DEFAULT_TEMPERATURE = 0.8f;
/**
* The lower-level API for the Anthropic service.
*/
public final AnthropicApi anthropicApi;
/**
* The default options used for the chat completion requests.
*/
private AnthropicChatOptions defaultOptions;
/**
* The retry template used to retry the OpenAI API calls.
*/
public final RetryTemplate retryTemplate;
/**
* Construct a new {@link AnthropicChatClient} instance.
* @param anthropicApi the lower-level API for the Anthropic service.
*/
public AnthropicChatClient(AnthropicApi anthropicApi) {
this(anthropicApi,
AnthropicChatOptions.builder()
.withModel(DEFAULT_MODEL_NAME)
.withMaxTokens(DEFAULT_MAX_TOKENS)
.withTemperature(DEFAULT_TEMPERATURE)
.build());
}
/**
* Construct a new {@link AnthropicChatClient} instance.
* @param anthropicApi the lower-level API for the Anthropic service.
* @param defaultOptions the default options used for the chat completion requests.
*/
public AnthropicChatClient(AnthropicApi anthropicApi, AnthropicChatOptions defaultOptions) {
this(anthropicApi, defaultOptions, RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
/**
* Construct a new {@link AnthropicChatClient} instance.
* @param anthropicApi the lower-level API for the Anthropic service.
* @param defaultOptions the default options used for the chat completion requests.
* @param retryTemplate the retry template used to retry the Anthropic API calls.
*/
public AnthropicChatClient(AnthropicApi anthropicApi, AnthropicChatOptions defaultOptions,
RetryTemplate retryTemplate) {
Assert.notNull(anthropicApi, "AnthropicApi must not be null");
Assert.notNull(defaultOptions, "DefaultOptions must not be null");
Assert.notNull(retryTemplate, "RetryTemplate must not be null");
this.anthropicApi = anthropicApi;
this.defaultOptions = defaultOptions;
this.retryTemplate = retryTemplate;
}
@Override
public ChatResponse call(Prompt prompt) {
ChatCompletionRequest request = createRequest(prompt, false);
return this.retryTemplate.execute(ctx -> {
ResponseEntity<ChatCompletion> completionEntity = this.anthropicApi.chatCompletionEntity(request);
return toChatResponse(completionEntity.getBody());
});
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
ChatCompletionRequest request = createRequest(prompt, true);
Flux<StreamResponse> response = this.anthropicApi.chatCompletionStream(request);
AtomicReference<ChatCompletionBuilder> chatCompletionReference = new AtomicReference<>();
// https://docs.anthropic.com/claude/reference/messages-streaming
return response.map(chunk -> {
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("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 (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.usage() != null) {
chatCompletionReference.get().withUsage(delta.usage());
}
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 {
chatCompletionReference.get().withType(chunk.type()).withContent(List.of());
}
return chatCompletionReference.get().build();
}).map(this::toChatResponse);
}
private ChatResponse toChatResponse(ChatCompletion chatCompletion) {
if (chatCompletion == null) {
logger.warn("Null chat completion returned");
return new ChatResponse(List.of());
}
List<Generation> generations = chatCompletion.content().stream().map(content -> {
return new Generation(content.text(), Map.of())
.withGenerationMetadata(ChatGenerationMetadata.from(chatCompletion.stopReason(), null));
}).toList();
return new ChatResponse(generations, AnthropicChatResponseMetadata.from(chatCompletion));
}
private String fromMediaData(Object mediaData) {
if (mediaData instanceof byte[] bytes) {
return Base64.getEncoder().encodeToString(bytes);
}
else if (mediaData instanceof String text) {
return text;
}
else {
throw new IllegalArgumentException("Unsupported media data type: " + mediaData.getClass().getSimpleName());
}
}
ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
List<RequestMessage> userMessages = prompt.getInstructions()
.stream()
.filter(m -> m.getMessageType() != MessageType.SYSTEM)
.map(m -> {
List<MediaContent> contents = new ArrayList<>(List.of(new MediaContent(m.getContent())));
if (!CollectionUtils.isEmpty(m.getMedia())) {
List<MediaContent> mediaContent = m.getMedia()
.stream()
.map(media -> new MediaContent(media.getMimeType().toString(),
this.fromMediaData(media.getData())))
.toList();
contents.addAll(mediaContent);
}
return new RequestMessage(contents, Role.valueOf(m.getMessageType().name()));
})
.toList();
String systemPrompt = prompt.getInstructions()
.stream()
.filter(m -> m.getMessageType() == MessageType.SYSTEM)
.map(m -> m.getContent())
.collect(Collectors.joining(System.lineSeparator()));
ChatCompletionRequest request = new ChatCompletionRequest(this.defaultOptions.getModel(), userMessages,
systemPrompt, this.defaultOptions.getMaxTokens(), this.defaultOptions.getTemperature(), stream);
if (prompt.getOptions() != null) {
if (prompt.getOptions() instanceof ChatOptions runtimeOptions) {
AnthropicChatOptions updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions,
ChatOptions.class, AnthropicChatOptions.class);
request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, ChatCompletionRequest.class);
}
else {
throw new IllegalArgumentException("Prompt options are not of type ChatOptions: "
+ prompt.getOptions().getClass().getSimpleName());
}
}
if (this.defaultOptions != null) {
request = ModelOptionsUtils.merge(request, this.defaultOptions, ChatCompletionRequest.class);
}
return request;
}
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;
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 ChatCompletion build() {
return new ChatCompletion(this.id, this.type, this.role, this.content, this.model, this.stopReason,
this.stopSequence, this.usage);
}
}
}

View File

@@ -0,0 +1,153 @@
/*
* 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;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest;
import org.springframework.ai.chat.prompt.ChatOptions;
/**
* The options to be used when sending a chat request to the Anthropic API.
*
* @author Christian Tzolov
* @since 1.0.0
*/
@JsonInclude(Include.NON_NULL)
public class AnthropicChatOptions implements ChatOptions {
// @formatter:off
private @JsonProperty("model") String model;
private @JsonProperty("max_tokens") Integer maxTokens;
private @JsonProperty("metadata") ChatCompletionRequest.Metadata metadata;
private @JsonProperty("stop_sequences") List<String> stopSequences;
private @JsonProperty("temperature") Float temperature;
private @JsonProperty("top_p") Float topP;
private @JsonProperty("top_k") Integer topK;
// @formatter:on
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final AnthropicChatOptions options = new AnthropicChatOptions();
public Builder withModel(String model) {
this.options.model = model;
return this;
}
public Builder withMaxTokens(Integer maxTokens) {
this.options.maxTokens = maxTokens;
return this;
}
public Builder withMetadata(ChatCompletionRequest.Metadata metadata) {
this.options.metadata = metadata;
return this;
}
public Builder withStopSequences(List<String> stopSequences) {
this.options.stopSequences = stopSequences;
return this;
}
public Builder withTemperature(Float temperature) {
this.options.temperature = temperature;
return this;
}
public Builder withTopP(Float topP) {
this.options.topP = topP;
return this;
}
public Builder withTopK(Integer topK) {
this.options.topK = topK;
return this;
}
public AnthropicChatOptions build() {
return this.options;
}
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public Integer getMaxTokens() {
return this.maxTokens;
}
public void setMaxTokens(Integer maxTokens) {
this.maxTokens = maxTokens;
}
public ChatCompletionRequest.Metadata getMetadata() {
return this.metadata;
}
public void setMetadata(ChatCompletionRequest.Metadata metadata) {
this.metadata = metadata;
}
public List<String> getStopSequences() {
return this.stopSequences;
}
public void setStopSequences(List<String> stopSequences) {
this.stopSequences = stopSequences;
}
@Override
public Float getTemperature() {
return this.temperature;
}
public void setTemperature(Float temperature) {
this.temperature = temperature;
}
@Override
public Float getTopP() {
return this.topP;
}
public void setTopP(Float topP) {
this.topP = topP;
}
public Integer getTopK() {
return this.topK;
}
public void setTopK(Integer topK) {
this.topK = topK;
}
}

View File

@@ -0,0 +1,43 @@
/*
* 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.aot;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage;
/**
* The AnthropicRuntimeHints class is responsible for registering runtime hints for
* Anthropic API classes.
*
* @author Christian Tzolov
* @since 1.0.0
*/
public class AnthropicRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
var mcs = MemberCategory.values();
for (var tr : findJsonAnnotatedClassesInPackage(AnthropicApi.class))
hints.reflection().registerType(tr, mcs);
}
}

View File

@@ -0,0 +1,384 @@
/*
* 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.List;
import java.util.Map;
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.model.ModelOptionsUtils;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
/**
* @author Christian Tzolov
* @since 1.0.0
*/
public class AnthropicApi {
public static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
public static final String DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
private static final Predicate<String> SSE_DONE_PREDICATE = "[DONE]"::equals;
private final RestClient restClient;
private WebClient webClient;
/**
* Create a new client api with DEFAULT_BASE_URL
* @param anthropicApiKey Anthropic api Key.
*/
public AnthropicApi(String anthropicApiKey) {
this(DEFAULT_BASE_URL, anthropicApiKey);
}
/**
* Create a new client api.
* @param baseUrl api base URL.
* @param anthropicApiKey Anthropic api Key.
*/
public AnthropicApi(String baseUrl, String anthropicApiKey) {
this(baseUrl, anthropicApiKey, DEFAULT_ANTHROPIC_VERSION, RestClient.builder(),
RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
}
/**
* Create a new client api.
* @param baseUrl api base URL.
* @param anthropicApiKey Anthropic api Key.
* @param restClientBuilder RestClient builder.
* @param responseErrorHandler Response error handler.
*/
public AnthropicApi(String baseUrl, String anthropicApiKey, String anthropicVersion,
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
Consumer<HttpHeaders> jsonContentHeaders = headers -> {
headers.add("x-api-key", anthropicApiKey);
headers.add("anthropic-version", anthropicVersion);
headers.setContentType(MediaType.APPLICATION_JSON);
};
this.restClient = restClientBuilder.baseUrl(baseUrl)
.defaultHeaders(jsonContentHeaders)
.defaultStatusHandler(responseErrorHandler)
.build();
this.webClient = WebClient.builder().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build();
}
/**
* Check the <a href="https://docs.anthropic.com/claude/docs/models-overview">Models
* overview</a> and <a href=
* "https://docs.anthropic.com/claude/docs/models-overview#model-comparison">model
* comparison</a> for additional details and options.
*/
public enum ChatModel {
// @formatter:off
CLAUDE_3_OPUS("claude-3-opus-20240229"),
CLAUDE_3_SONNET("claude-3-sonnet-20240229"),
CLAUDE_3_HAIKU("claude-3-haiku-20240307"),
// Legacy models
CLAUDE_2_1("claude-2.1"),
CLAUDE_2("claude-2.0"),
CLAUDE_INSTANT_1_2("claude-instant-1.2");
// @formatter:on
public final String value;
ChatModel(String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
}
/**
* @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
* additional details and options.
* @param messages Input messages.
* @param system System prompt. A system prompt is a way of providing context and
* instructions to Claude, such as specifying a particular goal or role. See our
* <a href="https://docs.anthropic.com/claude/docs/system-prompts">guide</a> to system
* prompts.
* @param maxTokens The maximum number of tokens to generate before stopping. Note
* that our models may stop before reaching this maximum. This parameter only
* specifies the absolute maximum number of tokens to generate. Different models have
* different maximum values for this parameter.
* @param metadata An object describing metadata about the request.
* @param stopSequences Custom text sequences that will cause the model to stop
* generating. Our models will normally stop when they have naturally completed their
* turn, which will result in a response stop_reason of "end_turn". If you want the
* model to stop generating when it encounters custom strings of text, you can use the
* stop_sequences parameter. If the model encounters one of the custom sequences, the
* response stop_reason value will be "stop_sequence" and the response stop_sequence
* value will contain the matched stop sequence.
* @param stream Whether to incrementally stream the response using server-sent
* events.
* @param temperature Amount of randomness injected into the response.Defaults to 1.0.
* Ranges from 0.0 to 1.0. Use temperature closer to 0.0 for analytical / multiple
* choice, and closer to 1.0 for creative and generative tasks. Note that even with
* temperature of 0.0, the results will not be fully deterministic.
* @param topP Use nucleus sampling. In nucleus sampling, we compute the cumulative
* distribution over all the options for each subsequent token in decreasing
* probability order and cut it off once it reaches a particular probability specified
* by top_p. You should either alter temperature or top_p, but not both. Recommended
* for advanced use cases only. You usually only need to use temperature.
* @param topK Only sample from the top K options for each subsequent token. Used to
* remove "long tail" low probability responses. Learn more technical details here.
* Recommended for advanced use cases only. You usually only need to use temperature.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionRequest( // @formatter:off
@JsonProperty("model") String model,
@JsonProperty("messages") List<RequestMessage> messages,
@JsonProperty("system") String system,
@JsonProperty("max_tokens") Integer maxTokens,
@JsonProperty("metadata") Metadata metadata,
@JsonProperty("stop_sequences") List<String> stopSequences,
@JsonProperty("stream") Boolean stream,
@JsonProperty("temperature") Float temperature,
@JsonProperty("top_p") Float topP,
@JsonProperty("top_k") Integer topK) {
// @formatter:on
public ChatCompletionRequest(String model, List<RequestMessage> messages, String system, Integer maxTokens,
Float temperature, Boolean stream) {
this(model, messages, system, maxTokens, null, null, stream, temperature, null, null);
}
/**
* @param userId An external identifier for the user who is associated with the
* request. This should be a uuid, hash value, or other opaque identifier.
* Anthropic may use this id to help detect abuse. Do not include any identifying
* information such as name, email address, or phone number.
*/
@JsonInclude(Include.NON_NULL)
public record Metadata(@JsonProperty("user_id") String userId) {
}
}
/**
* Input messages.
*
* Our models are trained to operate on alternating user and assistant conversational
* turns. When creating a new Message, you specify the prior conversational turns with
* the messages parameter, and the model then generates the next Message in the
* conversation. Each input message must be an object with a role and content. You can
* specify a single user-role message, or you can include multiple user and assistant
* messages. The first message must always use the user role. If the final message
* uses the assistant role, the response content will continue immediately from the
* content in that message. This can be used to constrain part of the model's
* response.
*
* @param content The contents of the message. Can be of one of String or
* MultiModalContent.
* @param role The role of the messages author. Could be one of the {@link Role}
* types.
*/
@JsonInclude(Include.NON_NULL)
public record RequestMessage( // @formatter:off
@JsonProperty("content") List<MediaContent> content,
@JsonProperty("role") Role role) {
// @formatter:on
}
/**
* @param type the content type can be "text" or "image".
* @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") String type,
@JsonProperty("source") Source source,
@JsonProperty("text") String text,
@JsonProperty("index") Integer index // applicable only for streaming responses.
) {
// @formatter:on
public MediaContent(String mediaType, String data) {
this(new Source(mediaType, data));
}
public MediaContent(Source source) {
this("image", source, null, null);
}
public MediaContent(String text) {
this("text", null, text, null);
}
/**
* The source of the media content. (Applicable for "image" types only)
*
* @param type The type of the media content. Only "base64" is supported at the
* moment.
* @param mediaType The media type of the content. For example, "image/png" or
* "image/jpeg".
* @param data The base64-encoded data of the content.
*/
@JsonInclude(Include.NON_NULL)
public record Source( // @formatter:off
@JsonProperty("type") String type,
@JsonProperty("media_type") String mediaType,
@JsonProperty("data") String data) {
// @formatter:on
public Source(String mediaType, String data) {
this("base64", mediaType, data);
}
}
}
/**
* @param id Unique object identifier. The format and length of IDs may change over
* time.
* @param type Object type. For Messages, this is always "message".
* @param role Conversational role of the generated message. This will always be
* "assistant".
* @param content Content generated by the model. This is an array of content blocks.
* @param model The model that handled the request.
* @param stopReason The reason the model stopped generating tokens. This will be one
* of "end_turn", "max_tokens", "stop_sequence", or "timeout".
* @param stopSequence Which custom stop sequence was generated, if any.
* @param usage Input and output token usage.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletion( // @formatter:off
@JsonProperty("id") String id,
@JsonProperty("type") String type,
@JsonProperty("role") Role role,
@JsonProperty("content") List<MediaContent> content,
@JsonProperty("model") String model,
@JsonProperty("stop_reason") String stopReason,
@JsonProperty("stop_sequence") String stopSequence,
@JsonProperty("usage") Usage usage) {
// @formatter:on
}
/**
* Usage statistics.
*
* @param inputTokens The number of input tokens which were used.
* @param outputTokens The number of output tokens which were used. completion).
*/
@JsonInclude(Include.NON_NULL)
public record Usage( // @formatter:off
@JsonProperty("input_tokens") Integer inputTokens,
@JsonProperty("output_tokens") Integer outputTokens) {
// @formatter:off
}
/**
* The role of the author of this message.
*/
public enum Role { // @formatter:off
@JsonProperty("user") USER,
@JsonProperty("assistant") ASSISTANT
// @formatter:on
}
/**
* 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) {
// @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.
*/
public ResponseEntity<ChatCompletion> 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);
}
/**
* Creates a streaming chat response for the given chat conversation.
* @param chatRequest The chat completion request. Must have the stream property set
* to true.
* @return Returns a {@link Flux} stream from chat completion chunks.
*/
public Flux<StreamResponse> 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.");
return this.webClient.post()
.uri("/v1/messages")
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
.retrieve()
.bodyToFlux(String.class)
.takeUntil(SSE_DONE_PREDICATE)
.filter(SSE_DONE_PREDICATE.negate())
.map(content -> ModelOptionsUtils.jsonToObject(content, StreamResponse.class));
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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.metadata;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.metadata.*;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* {@link ChatResponseMetadata} implementation for {@literal AnthropicApi}.
*
* @author Christian Tzolov
* @see ChatResponseMetadata
* @see RateLimit
* @see Usage
* @since 1.0.0
*/
public class AnthropicChatResponseMetadata implements ChatResponseMetadata {
protected static final String AI_METADATA_STRING = "{ @type: %1$s, id: %2$s, usage: %3$s, rateLimit: %4$s }";
public static AnthropicChatResponseMetadata from(AnthropicApi.ChatCompletion result) {
Assert.notNull(result, "Anthropic ChatCompletionResult must not be null");
AnthropicUsage usage = AnthropicUsage.from(result.usage());
return new AnthropicChatResponseMetadata(result.id(), usage);
}
private final String id;
@Nullable
private RateLimit rateLimit;
private final Usage usage;
protected AnthropicChatResponseMetadata(String id, AnthropicUsage usage) {
this(id, usage, null);
}
protected AnthropicChatResponseMetadata(String id, AnthropicUsage usage, @Nullable AnthropicRateLimit rateLimit) {
this.id = id;
this.usage = usage;
this.rateLimit = rateLimit;
}
public String getId() {
return this.id;
}
@Override
@Nullable
public RateLimit getRateLimit() {
RateLimit rl = this.rateLimit;
return rl != null ? rl : new EmptyRateLimit();
}
@Override
public Usage getUsage() {
Usage usage = this.usage;
return usage != null ? usage : new EmptyUsage();
}
public AnthropicChatResponseMetadata withRateLimit(RateLimit rateLimit) {
this.rateLimit = rateLimit;
return this;
}
@Override
public String toString() {
return AI_METADATA_STRING.formatted(getClass().getName(), getId(), getUsage(), getRateLimit());
}
}

View File

@@ -0,0 +1,91 @@
/*
* 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.metadata;
import java.time.Duration;
import org.springframework.ai.chat.metadata.RateLimit;
/**
* {@link RateLimit} implementation for {@literal OpenAI}.
*
* @author Christian Tzolov
* @since 1.0.0
*/
public class AnthropicRateLimit implements RateLimit {
private static final String RATE_LIMIT_STRING = "{ @type: %1$s, requestsLimit: %2$s, requestsRemaining: %3$s, requestsReset: %4$s, tokensLimit: %5$s; tokensRemaining: %6$s; tokensReset: %7$s }";
private final Long requestsLimit;
private final Long requestsRemaining;
private final Long tokensLimit;
private final Long tokensRemaining;
private final Duration requestsReset;
private final Duration tokensReset;
public AnthropicRateLimit(Long requestsLimit, Long requestsRemaining, Duration requestsReset, Long tokensLimit,
Long tokensRemaining, Duration tokensReset) {
this.requestsLimit = requestsLimit;
this.requestsRemaining = requestsRemaining;
this.requestsReset = requestsReset;
this.tokensLimit = tokensLimit;
this.tokensRemaining = tokensRemaining;
this.tokensReset = tokensReset;
}
@Override
public Long getRequestsLimit() {
return this.requestsLimit;
}
@Override
public Long getTokensLimit() {
return this.tokensLimit;
}
@Override
public Long getRequestsRemaining() {
return this.requestsRemaining;
}
@Override
public Long getTokensRemaining() {
return this.tokensRemaining;
}
@Override
public Duration getRequestsReset() {
return this.requestsReset;
}
@Override
public Duration getTokensReset() {
return this.tokensReset;
}
@Override
public String toString() {
return RATE_LIMIT_STRING.formatted(getClass().getName(), getRequestsLimit(), getRequestsRemaining(),
getRequestsReset(), getTokensLimit(), getTokensRemaining(), getTokensReset());
}
}

View File

@@ -0,0 +1,65 @@
/*
* 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.metadata;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.util.Assert;
/**
* {@link Usage} implementation for {@literal AnthropicApi}.
*
* @author Christian Tzolov
* @since 1.0.0
*/
public class AnthropicUsage implements Usage {
public static AnthropicUsage from(AnthropicApi.Usage usage) {
return new AnthropicUsage(usage);
}
private final AnthropicApi.Usage usage;
protected AnthropicUsage(AnthropicApi.Usage usage) {
Assert.notNull(usage, "AnthropicApi Usage must not be null");
this.usage = usage;
}
protected AnthropicApi.Usage getUsage() {
return this.usage;
}
@Override
public Long getPromptTokens() {
return getUsage().inputTokens().longValue();
}
@Override
public Long getGenerationTokens() {
return getUsage().outputTokens().longValue();
}
@Override
public Long getTotalTokens() {
return this.getPromptTokens() + this.getGenerationTokens();
}
@Override
public String toString() {
return getUsage().toString();
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.aot.hint.RuntimeHintsRegistrar=\
org.springframework.ai.anthropic.aot.AnthropicRuntimeHints

View File

@@ -0,0 +1,192 @@
/*
* 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;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.chat.StreamingChatClient;
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.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.parser.BeanOutputParser;
import org.springframework.ai.parser.ListOutputParser;
import org.springframework.ai.parser.MapOutputParser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
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 static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(classes = AnthropicTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+")
class AnthropicChatClientIT {
private static final Logger logger = LoggerFactory.getLogger(AnthropicChatClientIT.class);
@Autowired
protected ChatClient chatClient;
@Autowired
protected StreamingChatClient streamingChatClient;
@Value("classpath:/prompts/system-message.st")
private Resource systemResource;
@Test
void roleTest() {
UserMessage userMessage = new UserMessage(
"Tell me about 3 famous pirates from the Golden Age of Piracy and why they did.");
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource);
Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate"));
Prompt prompt = new Prompt(List.of(userMessage, systemMessage));
ChatResponse response = chatClient.call(prompt);
assertThat(response.getResults()).hasSize(1);
assertThat(response.getMetadata().getUsage().getGenerationTokens()).isGreaterThan(0);
assertThat(response.getMetadata().getUsage().getPromptTokens()).isGreaterThan(0);
assertThat(response.getMetadata().getUsage().getTotalTokens())
.isEqualTo(response.getMetadata().getUsage().getPromptTokens()
+ response.getMetadata().getUsage().getGenerationTokens());
Generation generation = response.getResults().get(0);
assertThat(generation.getOutput().getContent()).contains("Blackbeard");
assertThat(generation.getMetadata().getFinishReason()).isEqualTo("end_turn");
logger.info(response.toString());
}
@Test
void outputParser() {
DefaultConversionService conversionService = new DefaultConversionService();
ListOutputParser outputParser = new ListOutputParser(conversionService);
String format = outputParser.getFormat();
String template = """
List five {subject}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template,
Map.of("subject", "ice cream flavors", "format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = this.chatClient.call(prompt).getResult();
List<String> list = outputParser.parse(generation.getOutput().getContent());
assertThat(list).hasSize(5);
}
@Test
void mapOutputParser() {
MapOutputParser outputParser = new MapOutputParser();
String format = outputParser.getFormat();
String template = """
Provide me a List of {subject}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template,
Map.of("subject", "an array of numbers from 1 to 9 under they key name 'numbers'", "format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatClient.call(prompt).getResult();
Map<String, Object> result = outputParser.parse(generation.getOutput().getContent());
assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
}
record ActorsFilmsRecord(String actor, List<String> movies) {
}
@Test
void beanOutputParserRecords() {
BeanOutputParser<ActorsFilmsRecord> outputParser = new BeanOutputParser<>(ActorsFilmsRecord.class);
String format = outputParser.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks.
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatClient.call(prompt).getResult();
ActorsFilmsRecord actorsFilms = outputParser.parse(generation.getOutput().getContent());
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void beanStreamOutputParserRecords() {
BeanOutputParser<ActorsFilmsRecord> outputParser = new BeanOutputParser<>(ActorsFilmsRecord.class);
String format = outputParser.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks.
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
String generationTextFromStream = streamingChatClient.stream(prompt)
.collectList()
.block()
.stream()
.map(ChatResponse::getResults)
.flatMap(List::stream)
.map(Generation::getOutput)
.map(AssistantMessage::getContent)
.collect(Collectors.joining());
ActorsFilmsRecord actorsFilms = outputParser.parse(generationTextFromStream);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void multiModalityTest() throws IOException {
byte[] imageData = new ClassPathResource("/test.png").getContentAsByteArray();
var userMessage = new UserMessage("Explain what do you see o this picture?",
List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData)));
ChatResponse response = chatClient.call(new Prompt(List.of(userMessage)));
logger.info(response.getResult().getOutput().getContent());
assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple", "basket");
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.util.StringUtils;
@SpringBootConfiguration
public class AnthropicTestConfiguration {
@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 AnthropicChatClient openAiChatClient(AnthropicApi api) {
AnthropicChatClient anthropicChatClient = new AnthropicChatClient(api);
return anthropicChatClient;
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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;
import org.junit.jupiter.api.Test;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.prompt.Prompt;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
*/
public class ChatCompletionRequestTests {
@Test
public void createRequestWithChatOptions() {
var client = new AnthropicChatClient(new AnthropicApi("TEST"),
AnthropicChatOptions.builder().withModel("DEFAULT_MODEL").withTemperature(66.6f).build());
var request = client.createRequest(new Prompt("Test message content"), false);
assertThat(request.messages()).hasSize(1);
assertThat(request.stream()).isFalse();
assertThat(request.model()).isEqualTo("DEFAULT_MODEL");
assertThat(request.temperature()).isEqualTo(66.6f);
request = client.createRequest(new Prompt("Test message content",
AnthropicChatOptions.builder().withModel("PROMPT_MODEL").withTemperature(99.9f).build()), true);
assertThat(request.messages()).hasSize(1);
assertThat(request.stream()).isTrue();
assertThat(request.model()).isEqualTo("PROMPT_MODEL");
assertThat(request.temperature()).isEqualTo(99.9f);
}
}

View File

@@ -0,0 +1,44 @@
/*
* 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.aot;
import org.junit.jupiter.api.Test;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.TypeReference;
import java.util.Set;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage;
import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.reflection;
class AnthropicRuntimeHintsTests {
@Test
void registerHints() {
RuntimeHints runtimeHints = new RuntimeHints();
AnthropicRuntimeHints anthropicRuntimeHints = new AnthropicRuntimeHints();
anthropicRuntimeHints.registerHints(runtimeHints, null);
Set<TypeReference> jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage(AnthropicApi.class);
for (TypeReference jsonAnnotatedClass : jsonAnnotatedClasses) {
assertThat(runtimeHints).matches(reflection().onType(jsonAnnotatedClass));
}
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.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.ChatCompletionRequest;
import org.springframework.ai.anthropic.api.AnthropicApi.MediaContent;
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;
/**
* @author Christian Tzolov
*/
@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+")
public class AnthropicApiIT {
AnthropicApi anthropicApi = new AnthropicApi(System.getenv("ANTHROPIC_API_KEY"));
@Test
void chatCompletionEntity() {
RequestMessage chatCompletionMessage = new RequestMessage(List.of(new MediaContent("Tell me a Joke?")),
Role.USER);
ResponseEntity<ChatCompletion> response = anthropicApi
.chatCompletionEntity(new ChatCompletionRequest(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue(),
List.of(chatCompletionMessage), null, 100, 0.8f, false));
System.out.println(response);
assertThat(response).isNotNull();
assertThat(response.getBody()).isNotNull();
}
@Test
void chatCompletionStream() {
RequestMessage chatCompletionMessage = new RequestMessage(List.of(new MediaContent("Tell me a Joke?")),
Role.USER);
Flux<StreamResponse> 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();
assertThat(bla).isNotNull();
bla.stream().forEach(r -> System.out.println(r));
}
}

View File

@@ -0,0 +1,4 @@
"You are a helpful AI assistant. Your name is {name}.
You are an AI assistant that helps people find information.
Your name is {name}
You should reply to the user's request with your name and also in the style of a {voice}.

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB