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,59 @@
/*
* 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.autoconfigure.anthropic;
import org.springframework.ai.anthropic.AnthropicChatClient;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
/**
* @author Christian Tzolov
* @since 1.0.0
*/
@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class })
@EnableConfigurationProperties({ AnthropicChatProperties.class, AnthropicConnectionProperties.class })
@ConditionalOnClass(AnthropicApi.class)
@ConditionalOnProperty(prefix = AnthropicChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
public class AnthropicAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public AnthropicApi anthropicApi(AnthropicConnectionProperties connectionProperties,
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
return new AnthropicApi(connectionProperties.getBaseUrl(), connectionProperties.getApiKey(),
connectionProperties.getVersion(), restClientBuilder, responseErrorHandler);
}
@Bean
@ConditionalOnMissingBean
public AnthropicChatClient anthropicChatClient(AnthropicApi anthropicApi, AnthropicChatProperties chatProperties,
RetryTemplate retryTemplate) {
return new AnthropicChatClient(anthropicApi, chatProperties.getOptions(), retryTemplate);
}
}

View File

@@ -0,0 +1,63 @@
/*
* 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.autoconfigure.anthropic;
import org.springframework.ai.anthropic.AnthropicChatClient;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
* Anthropic Chat autoconfiguration properties.
*
* @author Christian Tzolov
* @since 1.0.0
*/
@ConfigurationProperties(AnthropicChatProperties.CONFIG_PREFIX)
public class AnthropicChatProperties {
public static final String CONFIG_PREFIX = "spring.ai.anthropic.chat";
/**
* Enable Anthropic chat client.
*/
private boolean enabled = true;
/**
* Client lever Ollama options. Use this property to configure generative temperature,
* topK and topP and alike parameters. The null values are ignored defaulting to the
* generative's defaults.
*/
@NestedConfigurationProperty
private AnthropicChatOptions options = AnthropicChatOptions.builder()
.withModel(AnthropicChatClient.DEFAULT_MODEL_NAME)
.withMaxTokens(AnthropicChatClient.DEFAULT_MAX_TOKENS)
.withTemperature(AnthropicChatClient.DEFAULT_TEMPERATURE)
.build();
public AnthropicChatOptions getOptions() {
return this.options;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isEnabled() {
return this.enabled;
}
}

View File

@@ -0,0 +1,71 @@
/*
* 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.autoconfigure.anthropic;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Anthropic API connection properties.
*
* @author Christian Tzolov
* @since 1.0.0
*/
@ConfigurationProperties(AnthropicConnectionProperties.CONFIG_PREFIX)
public class AnthropicConnectionProperties {
public static final String CONFIG_PREFIX = "spring.ai.anthropic";
/**
* Anthropic API access key.
*/
private String apiKey;
/**
* Anthropic API base URL.
*/
private String baseUrl = AnthropicApi.DEFAULT_BASE_URL;
/**
* Anthropic API version.
*/
private String version = AnthropicApi.DEFAULT_ANTHROPIC_VERSION;
public String getApiKey() {
return this.apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getBaseUrl() {
return this.baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public String getVersion() {
return this.version;
}
public void setVersion(String version) {
this.version = version;
}
}

View File

@@ -26,3 +26,4 @@ org.springframework.ai.autoconfigure.vectorstore.qdrant.QdrantVectorStoreAutoCon
org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration
org.springframework.ai.autoconfigure.postgresml.PostgresMlAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.mongo.MongoDBAtlasVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.anthropic.AnthropicAutoConfiguration

View File

@@ -0,0 +1,80 @@
/*
* 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.autoconfigure.anthropic;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import reactor.core.publisher.Flux;
import org.springframework.ai.anthropic.AnthropicChatClient;
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".*")
public class AnthropicAutoConfigurationIT {
private static final Log logger = LogFactory.getLog(AnthropicAutoConfigurationIT.class);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withPropertyValues("spring.ai.anthropic.apiKey=" + System.getenv("ANTHROPIC_API_KEY"))
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class));
@Test
void generate() {
contextRunner.run(context -> {
AnthropicChatClient chatClient = context.getBean(AnthropicChatClient.class);
String response = chatClient.call("Hello");
assertThat(response).isNotEmpty();
logger.info("Response: " + response);
});
}
@Test
void generateStreaming() {
contextRunner.run(context -> {
AnthropicChatClient chatClient = context.getBean(AnthropicChatClient.class);
Flux<ChatResponse> responseFlux = chatClient.stream(new Prompt(new UserMessage("Hello")));
String response = responseFlux.collectList()
.block()
.stream()
.map(ChatResponse::getResults)
.flatMap(List::stream)
.map(Generation::getOutput)
.map(AssistantMessage::getContent)
.collect(Collectors.joining());
assertThat(response).isNotEmpty();
logger.info("Response: " + response);
});
}
}

View File

@@ -0,0 +1,127 @@
/*
* 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.autoconfigure.anthropic;
import org.junit.jupiter.api.Test;
import org.springframework.ai.anthropic.AnthropicChatClient;
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit Tests for {@link AnthropicChatProperties}, {@link AnthropicConnectionProperties}.
*/
public class AnthropicPropertiesTests {
@Test
public void connectionProperties() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.anthropic.base-url=TEST_BASE_URL",
"spring.ai.anthropic.api-key=abc123",
"spring.ai.anthropic.chat.options.model=MODEL_XYZ",
"spring.ai.anthropic.chat.options.temperature=0.55")
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class))
.run(context -> {
var chatProperties = context.getBean(AnthropicChatProperties.class);
var connectionProperties = context.getBean(AnthropicConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f);
// enabled is true by default
assertThat(chatProperties.isEnabled()).isTrue();
});
}
@Test
public void chatOptionsTest() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.anthropic.api-key=API_KEY",
"spring.ai.anthropic.base-url=TEST_BASE_URL",
"spring.ai.anthropic.chat.options.model=MODEL_XYZ",
"spring.ai.anthropic.chat.options.max-tokens=123",
"spring.ai.anthropic.chat.options.metadata.user-id=MyUserId",
"spring.ai.anthropic.chat.options.stop_sequences=boza,koza",
"spring.ai.anthropic.chat.options.temperature=0.55",
"spring.ai.anthropic.chat.options.top-p=0.56",
"spring.ai.anthropic.chat.options.top-k=100"
)
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class))
.run(context -> {
var chatProperties = context.getBean(AnthropicChatProperties.class);
var connectionProperties = context.getBean(AnthropicConnectionProperties.class);
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123);
assertThat(chatProperties.getOptions().getStopSequences()).contains("boza", "koza");
assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f);
assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56f);
assertThat(chatProperties.getOptions().getTopK()).isEqualTo(100);
assertThat(chatProperties.getOptions().getMetadata().userId()).isEqualTo("MyUserId");
});
}
@Test
public void chatCompletionDisabled() {
// It is enabled by default
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(AnthropicChatProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(AnthropicChatClient.class)).isNotEmpty();
});
// Explicitly enable the chat auto-configuration.
new ApplicationContextRunner().withPropertyValues("spring.ai.anthropic.chat.enabled=true")
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(AnthropicChatProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(AnthropicChatClient.class)).isNotEmpty();
});
// Explicitly disable the chat auto-configuration.
new ApplicationContextRunner().withPropertyValues("spring.ai.anthropic.chat.enabled=false")
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, AnthropicAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(AnthropicChatProperties.class)).isEmpty();
assertThat(context.getBeansOfType(AnthropicChatClient.class)).isEmpty();
});
}
}