From 26b1ccdc5768257a0d5feb9243fbee063dd3267f Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 19 Dec 2023 12:58:37 +0100 Subject: [PATCH] Bedrock: Add Titan Chat support --- spring-ai-bedrock/README.md | 14 +- spring-ai-bedrock/README_COHERE_CHAT.md | 70 ------- spring-ai-bedrock/README_TITAN_CHAT.md | 84 ++++++++- .../ai/bedrock/api/AbstractBedrockApi.java | 3 +- .../bedrock/titan/BedrockTitanChatClient.java | 132 +++++++++++++ .../titan/api/TitanChatBedrockApi.java | 32 +++- .../titan/BedrockTitanChatClientIT.java | 173 ++++++++++++++++++ .../titan/api/TitanChatBedrockApiIT.java | 4 +- .../resources/doc/Bedrock Titan Chat API.jpg | Bin 0 -> 425837 bytes .../BedrockTitanChatAutoConfiguration.java | 68 +++++++ .../titan/BedrockTitanChatProperties.java | 118 ++++++++++++ .../BedrockTitanChatAutoConfigurationIT.java | 157 ++++++++++++++++ 12 files changed, 769 insertions(+), 86 deletions(-) create mode 100644 spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClient.java create mode 100644 spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClientIT.java create mode 100644 spring-ai-bedrock/src/test/resources/doc/Bedrock Titan Chat API.jpg create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanChatAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanChatProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/titan/BedrockTitanChatAutoConfigurationIT.java diff --git a/spring-ai-bedrock/README.md b/spring-ai-bedrock/README.md index c61e82c0d..f13295e55 100644 --- a/spring-ai-bedrock/README.md +++ b/spring-ai-bedrock/README.md @@ -74,10 +74,10 @@ For example to enable the bedrock Llama2 Chat client you need to set the Next you can use the `spring.ai.bedrock...*` properties to configure each model as provided in its documentation: -* [Spring AI Bedrock Llama2 Chat](./README_LLAMA2_CHAT.md) - `spring.ai.bedrock.llama2.chat=true` -* [Spring AI Bedrock Cohere Chat](./README_COHERE_CHAT.md) - `spring.ai.bedrock.cohere.chat=true` -* [Spring AI Bedrock Cohere Embedding](./README_COHERE_EMBEDDING.md) - `spring.ai.bedrock.cohere.embedding=true` -* [Spring AI Bedrock Anthropic Chat](./README_ANTHROPIC_CHAT.md) - `spring.ai.bedrock.anthropic.chat=true` -* (WIP) [Spring AI Bedrock Titan Chat](./README_TITAN_CHAT.md) - `spring.ai.bedrock.titan.chat=true` -* [Spring AI Bedrock Titan Embedding](./README_TITAN_EMBEDDING.md) - `spring.ai.bedrock.titan.embedding=true` -* (WIP) [Spring AI Bedrock Ai21 Jurassic2 Chat](./README_JURASSIC2_CHAT.md) - `spring.ai.bedrock.jurassic2.chat=true` +* [Spring AI Bedrock Llama2 Chat](./README_LLAMA2_CHAT.md) - `spring.ai.bedrock.llama2.chat.enabled=true` +* [Spring AI Bedrock Cohere Chat](./README_COHERE_CHAT.md) - `spring.ai.bedrock.cohere.chat.enabled=true` +* [Spring AI Bedrock Cohere Embedding](./README_COHERE_EMBEDDING.md) - `spring.ai.bedrock.cohere.embedding.enabled=true` +* [Spring AI Bedrock Anthropic Chat](./README_ANTHROPIC_CHAT.md) - `spring.ai.bedrock.anthropic.chat.enabled=true` +* [Spring AI Bedrock Titan Chat](./README_TITAN_CHAT.md) - `spring.ai.bedrock.titan.chat.enabled=true` +* [Spring AI Bedrock Titan Embedding](./README_TITAN_EMBEDDING.md) - `spring.ai.bedrock.titan.embedding.enabled=true` +* (WIP) [Spring AI Bedrock Ai21 Jurassic2 Chat](./README_JURASSIC2_CHAT.md) - `spring.ai.bedrock.jurassic2.chat.enabled=true` diff --git a/spring-ai-bedrock/README_COHERE_CHAT.md b/spring-ai-bedrock/README_COHERE_CHAT.md index 75b9d1466..5cb1f451e 100644 --- a/spring-ai-bedrock/README_COHERE_CHAT.md +++ b/spring-ai-bedrock/README_COHERE_CHAT.md @@ -108,73 +108,3 @@ Use the `BedrockCohereChatProperties` to configure the Bedrock Cohere Chat clien | spring.ai.bedrock.cohere.chat.logitBiasBias | Prevents the model from generating unwanted tokens or incentivize the model to include desired tokens. | AWS Bedrock default | | spring.ai.bedrock.cohere.chat.truncate | Specifies how the API handles inputs longer than the maximum token length | AWS Bedrock default | -### 2.3 CohereEmbeddingBedrockApi - -[CohereEmbeddingBedrockApi](./src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java) provides is lightweight Java client on top of AWS Bedrock [Cohere Embed models](https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-embed.html). - -Following class diagram illustrates the Llama2ChatBedrockApi interface and building blocks: - -![CohereEmbeddingBedrockApi Class Diagram](./src/test/resources/doc/Bedrock%20Cohere%20Embedding%20API.jpg) - -The CohereEmbeddingBedrockApi supports the `cohere.embed-english-v3` and `cohere.embed-multilingual-v3` models for single and batch embedding computation. - -Here is a simple snippet how to use the api programmatically: - -```java -CohereEmbeddingBedrockApi api = new CohereEmbeddingBedrockApi( - CohereEmbeddingModel.COHERE_EMBED_MULTILINGUAL_V1.id(), - EnvironmentVariableCredentialsProvider.create(), - Region.US_EAST_1.id(), new ObjectMapper()); - -CohereEmbeddingRequest request = new CohereEmbeddingRequest( - List.of("I like to eat apples", "I like to eat oranges"), - CohereEmbeddingRequest.InputType.search_document, - CohereEmbeddingRequest.Truncate.NONE); - -CohereEmbeddingResponse response = api.embedding(request); - -assertThat(response.embeddings()).hasSize(2); -assertThat(response.embeddings().get(0)).hasSize(1024); -``` - -### 2.4 BedrockCohereEmbeddingClient - -[BedrockCohereEmbeddingClient](./src/main/java/org/springframework/ai/bedrock/cohere/BedrockCohereEmbeddingClient.java) implements the Spring-Ai `EmbeddingClient` on top of the `CohereEmbeddingBedrockApi`. - -You can use like this: - -```java -@Bean -public CohereEmbeddingBedrockApi cohereEmbeddingApi() { - return new CohereEmbeddingBedrockApi(CohereEmbeddingModel.COHERE_EMBED_MULTILINGUAL_V1.id(), - EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper()); -} - -@Bean -public BedrockCohereEmbeddingClient cohereAiEmbedding(CohereEmbeddingBedrockApi cohereEmbeddingApi) { - return new BedrockCohereEmbeddingClient(cohereEmbeddingApi); -} -``` - -or you can leverage the `spring-ai-bedrock-ai-spring-boot-starter` Boot starter. For this add the following dependency: - -```xml - - spring-ai-bedrock-ai-spring-boot-starter - org.springframework.ai - 0.8.0-SNAPSHOT - -``` - -**NOTE:** You have to enable the Bedrock Cohere chat client with `spring.ai.bedrock.cohere.embedding.enabled=true`. -By default the client is disabled. - -Use the `BedrockCohereEmbeddingProperties` to configure the Bedrock Cohere Chat client: - -| Property | Description | Default | -| ------------- | ------------- | ------------- | -| spring.ai.bedrock.cohere.embedding.enable | Enable Bedrock Cohere chat client. Disabled by default | false | -| spring.ai.bedrock.cohere.embedding.awsRegion | AWS region to use. | us-east-1 | -| spring.ai.bedrock.cohere.embedding.model | The model id to use. See the `CohereEmbeddingModel` for the supported models. | cohere.embed-multilingual-v3 | -| spring.ai.bedrock.cohere.embedding.inputType | Prepends special tokens to differentiate each type from one another. You should not mix different types together, except when mixing types for for search and retrieval. In this case, embed your corpus with the search_document type and embedded queries with type search_query type. | search_document | -| spring.ai.bedrock.cohere.embedding.truncate | Specifies how the API handles inputs longer than the maximum token length. | NONE | diff --git a/spring-ai-bedrock/README_TITAN_CHAT.md b/spring-ai-bedrock/README_TITAN_CHAT.md index 0ffe7b1bf..ba6f585a2 100644 --- a/spring-ai-bedrock/README_TITAN_CHAT.md +++ b/spring-ai-bedrock/README_TITAN_CHAT.md @@ -1,5 +1,83 @@ -# (WIP) Bedrock Titan Chat +# 1. Bedrock Titan Chat -Use the [TitanChatBedrockApi.java](src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java) Bedrock Titan chat client to implement `AiClient` and `AiStreamClient`. +## 1.1 TitanChatBedrockApi -Consult the the existing Llama2 and Cohere implementation. \ No newline at end of file +[TitanChatBedrockApi](./src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java) provides is lightweight Java client on top of AWS Bedrock [Titan text models](https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-titan-text.html). + +Following class diagram illustrates the Llama2ChatBedrockApi interface and building blocks: + +![TitanChatBedrockApi Class Diagram](./src/test/resources/doc/Bedrock%20Titan%20Chat%20API.jpg) + +The TitanChatBedrockApi supports the `amazon.titan-text-lite-v1` and `amazon.titan-text-express-v1` models for bot synchronous (e.g. `chatCompletion()`) and streaming (e.g. `chatCompletionStream()`) responses. + +Here is a simple snippet how to use the api programmatically: + +```java +TitanChatBedrockApi titanBedrockApi = new TitanChatBedrockApi(TitanChatCompletionModel.TITAN_TEXT_EXPRESS_V1.id(), + Region.EU_CENTRAL_1.id()); + +TitanChatRequest titanChatRequest = TitanChatRequest.builder("Give me the names of 3 famous pirates?") + .withTemperature(0.5f) + .withTopP(0.9f) + .withMaxTokenCount(100) + .withStopSequences(List.of("|")) + .build(); + +TitanChatResponse response = titanBedrockApi.chatCompletion(titanChatRequest); + +assertThat(response.results()).hasSize(1); +assertThat(response.results().get(0).outputText()).contains("Blackbeard"); + +Flux response = titanBedrockApi.chatCompletionStream(titanChatRequest); + +List results = response.collectList().block(); +assertThat(results.stream().map(TitanChatResponseChunk::outputText).collect(Collectors.joining("\n"))) + .contains("Blackbeard"); +``` + +## 1.2 BedrockTitanChatClient + +[BedrockTitanChatClient](./src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClient.java) implements the Spring-Ai `AiClient` and `AiStreamingClient` on top of the `TitanChatBedrockApi`. + +You can use like this: + +```java +@Bean +public TitanChatBedrockApi titanApi() { + return new TitanChatBedrockApi(TitanChatModel.TITAN_TEXT_EXPRESS_V1.id(), + EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper()); +} + +@Bean +public BedrockTitanChatClient titanChatClient(TitanChatBedrockApi titanApi) { + return new BedrockTitanChatClient(titanApi); +} + +``` + +or you can leverage the `spring-ai-bedrock-ai-spring-boot-starter` Boot starter. For this add the following dependency: + +```xml + + spring-ai-bedrock-ai-spring-boot-starter + org.springframework.ai + 0.8.0-SNAPSHOT + +``` + +**NOTE:** You have to enable the Bedrock Titan chat client with `spring.ai.bedrock.titan.chat.enabled=true`. +By default the client is disabled. + +Use the `BedrockTitanChatProperties` to configure the Bedrock Titan Chat client: + +| Property | Description | Default | +| ------------- | ------------- | ------------- | +| spring.ai.bedrock.embedding.aws.region | AWS region to use. | us-east-1 | +| spring.ai.bedrock.embedding.aws.accessKey | AWS credentials access key. | | +| spring.ai.bedrock.embedding.aws.secretKey | AWS credentials secret key. | | +| spring.ai.bedrock.titan.chat.enable | Enable Bedrock Titan chat client. Disabled by default | false | +| spring.ai.bedrock.titan.chat.model | The model id to use. See the `TitanChatModel` for the supported models. | amazon.titan-text-express-v1 | +| spring.ai.bedrock.titan.chat.temperature | Controls the randomness of the output. Values can range over [0.0,1.0] | 0.7 | +| spring.ai.bedrock.titan.chat.topP | The maximum cumulative probability of tokens to consider when sampling. | AWS Bedrock default | +| spring.ai.bedrock.titan.chat.maxTokenCount | Specify the maximum number of tokens to use in the generated response. | AWS Bedrock default | +| spring.ai.bedrock.titan.chat.stopSequences | Configure up to four sequences that the model recognizes. | AWS Bedrock default | diff --git a/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/api/AbstractBedrockApi.java b/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/api/AbstractBedrockApi.java index 8db590aa8..f656e93ca 100644 --- a/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/api/AbstractBedrockApi.java +++ b/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/api/AbstractBedrockApi.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -92,7 +93,7 @@ public abstract class AbstractBedrockApi { ObjectMapper objectMapper) { this.modelId = modelId; - this.objectMapper = objectMapper; + this.objectMapper = objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); this.credentialsProvider = credentialsProvider; this.region = region; diff --git a/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClient.java b/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClient.java new file mode 100644 index 000000000..919f6399a --- /dev/null +++ b/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClient.java @@ -0,0 +1,132 @@ +/* + * Copyright 2023-2023 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.bedrock.titan; + +import java.util.List; + +import reactor.core.publisher.Flux; + +import org.springframework.ai.bedrock.MessageToPromptConverter; +import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi; +import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatRequest; +import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatResponse; +import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatResponseChunk; +import org.springframework.ai.client.AiClient; +import org.springframework.ai.client.AiResponse; +import org.springframework.ai.client.AiStreamClient; +import org.springframework.ai.client.Generation; +import org.springframework.ai.metadata.ChoiceMetadata; +import org.springframework.ai.metadata.Usage; +import org.springframework.ai.prompt.Prompt; + +/** + * @author Christian Tzolov + * @since 0.8.0 + */ +public class BedrockTitanChatClient implements AiClient, AiStreamClient { + + private final TitanChatBedrockApi chatApi; + + private Float temperature; + + private Float topP; + + private Integer maxTokenCount; + + private List stopSequences; + + public BedrockTitanChatClient(TitanChatBedrockApi chatApi) { + this.chatApi = chatApi; + } + + public BedrockTitanChatClient withTemperature(Float temperature) { + this.temperature = temperature; + return this; + } + + public BedrockTitanChatClient withTopP(Float topP) { + this.topP = topP; + return this; + } + + public BedrockTitanChatClient withMaxTokenCount(Integer maxTokens) { + this.maxTokenCount = maxTokens; + return this; + } + + public BedrockTitanChatClient withStopSequences(List stopSequences) { + this.stopSequences = stopSequences; + return this; + } + + @Override + public AiResponse generate(Prompt prompt) { + TitanChatResponse response = this.chatApi.chatCompletion(this.createRequest(prompt, false)); + List generations = response.results().stream().map(result -> { + return new Generation(result.outputText()); + }).toList(); + + return new AiResponse(generations); + } + + @Override + public Flux generateStream(Prompt prompt) { + return this.chatApi.chatCompletionStream(this.createRequest(prompt, true)).map(chunk -> { + + Generation generation = new Generation(chunk.outputText()); + + if (chunk.amazonBedrockInvocationMetrics() != null) { + String completionReason = chunk.completionReason().name(); + generation = generation + .withChoiceMetadata(ChoiceMetadata.from(completionReason, chunk.amazonBedrockInvocationMetrics())); + } + else if (chunk.inputTextTokenCount() != null && chunk.totalOutputTextTokenCount() != null) { + String completionReason = chunk.completionReason().name(); + generation = generation.withChoiceMetadata(ChoiceMetadata.from(completionReason, extractUsage(chunk))); + + } + return new AiResponse(List.of(generation)); + }); + } + + private TitanChatRequest createRequest(Prompt prompt, boolean stream) { + final String promptValue = MessageToPromptConverter.create().toPrompt(prompt.getMessages()); + + return TitanChatRequest.builder(promptValue) + .withTemperature(this.temperature) + .withTopP(this.topP) + .withMaxTokenCount(this.maxTokenCount) + .withStopSequences(this.stopSequences) + .build(); + } + + private Usage extractUsage(TitanChatResponseChunk response) { + return new Usage() { + + @Override + public Long getPromptTokens() { + return response.inputTextTokenCount().longValue(); + } + + @Override + public Long getGenerationTokens() { + return response.totalOutputTextTokenCount().longValue(); + } + }; + } + +} diff --git a/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java b/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java index 50009cf7f..244730a9b 100644 --- a/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java +++ b/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java @@ -20,7 +20,9 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; import reactor.core.publisher.Flux; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import org.springframework.ai.bedrock.api.AbstractBedrockApi; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatRequest; @@ -45,6 +47,19 @@ public class TitanChatBedrockApi extends super(modelId, region); } + /** + * Create a new TitanChatBedrockApi instance using the provided credentials provider, region and object mapper. + * + * @param modelId The model id to use. See the {@link TitanChatModel} for the supported models. + * @param credentialsProvider The credentials provider to connect to AWS. + * @param region The AWS region to use. + * @param objectMapper The object mapper to use for JSON serialization and deserialization. + */ + public TitanChatBedrockApi(String modelId, AwsCredentialsProvider credentialsProvider, String region, + ObjectMapper objectMapper) { + super(modelId, credentialsProvider, region, objectMapper); + } + /** * TitanChatRequest encapsulates the request parameters for the Titan chat model. * @@ -76,6 +91,11 @@ public class TitanChatBedrockApi extends @JsonProperty("stopSequences") List stopSequences) { } + /** + * Create a new TitanChatRequest builder. + * @param inputText The prompt to use for the chat. + * @return A new TitanChatRequest builder. + */ public static Builder builder(String inputText) { return new Builder(inputText); } @@ -162,10 +182,16 @@ public class TitanChatBedrockApi extends * The response was fully generated. */ FINISH, + /** * The response was truncated because of the response length you set. */ - LENGTH + LENGTH, + + /** + * The response was truncated because of restrictions. + */ + CONTENT_FILTERED } } @@ -191,7 +217,7 @@ public class TitanChatBedrockApi extends /** * Titan models version. */ - public enum TitanChatCompletionModel { + public enum TitanChatModel { /** * amazon.titan-text-lite-v1 @@ -212,7 +238,7 @@ public class TitanChatBedrockApi extends return id; } - TitanChatCompletionModel(String value) { + TitanChatModel(String value) { this.id = value; } } diff --git a/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClientIT.java b/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClientIT.java new file mode 100644 index 000000000..87dd7b7d2 --- /dev/null +++ b/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClientIT.java @@ -0,0 +1,173 @@ +package org.springframework.ai.bedrock.titan; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; +import software.amazon.awssdk.regions.Region; + +import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi; +import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatModel; +import org.springframework.ai.client.AiResponse; +import org.springframework.ai.client.Generation; +import org.springframework.ai.parser.BeanOutputParser; +import org.springframework.ai.parser.ListOutputParser; +import org.springframework.ai.parser.MapOutputParser; +import org.springframework.ai.prompt.Prompt; +import org.springframework.ai.prompt.PromptTemplate; +import org.springframework.ai.prompt.SystemPromptTemplate; +import org.springframework.ai.prompt.messages.Message; +import org.springframework.ai.prompt.messages.UserMessage; +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.Resource; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") +class BedrockTitanChatClientIT { + + @Autowired + private BedrockTitanChatClient client; + + @Value("classpath:/prompts/system-message.st") + private Resource systemResource; + + @Test + void roleTest() { + String request = "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did."; + String name = "Bob"; + String voice = "pirate"; + UserMessage userMessage = new UserMessage(request); + SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource); + Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", name, "voice", voice)); + Prompt prompt = new Prompt(List.of(userMessage, systemMessage)); + AiResponse response = client.generate(prompt); + assertThat(response.getGeneration().getContent()).contains("Blackbeard"); + } + + @Disabled("TODO: Fix the parser instructions to return the correct format") + @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.client.generate(prompt).getGeneration(); + + List list = outputParser.parse(generation.getContent()); + assertThat(list).hasSize(5); + } + + @Disabled("TODO: Fix the parser instructions to return the correct format") + @Test + void mapOutputParser() { + MapOutputParser outputParser = new MapOutputParser(); + + String format = outputParser.getFormat(); + String template = """ + Remove Markdown code blocks from the output. + 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 = client.generate(prompt).getGeneration(); + + Map result = outputParser.parse(generation.getContent()); + assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9)); + + } + + record ActorsFilmsRecord(String actor, List movies) { + } + + @Disabled("TODO: Fix the parser instructions to return the correct format") + @Test + void beanOutputParserRecords() { + + BeanOutputParser outputParser = new BeanOutputParser<>(ActorsFilmsRecord.class); + + String format = outputParser.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks. + {format} + Remove Markdown code blocks from the output. + """; + PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = client.generate(prompt).getGeneration(); + + ActorsFilmsRecord actorsFilms = outputParser.parse(generation.getContent()); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Disabled("TODO: Fix the parser instructions to return the correct format") + @Test + void beanStreamOutputParserRecords() { + + BeanOutputParser outputParser = new BeanOutputParser<>(ActorsFilmsRecord.class); + + String format = outputParser.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks. + {format} + Remove Markdown code blocks from the output. + """; + PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + + String generationTextFromStream = client.generateStream(prompt) + .collectList() + .block() + .stream() + .map(AiResponse::getGenerations) + .flatMap(List::stream) + .map(Generation::getContent) + .collect(Collectors.joining()); + + ActorsFilmsRecord actorsFilms = outputParser.parse(generationTextFromStream); + System.out.println(actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @SpringBootConfiguration + public static class TestConfiguration { + + @Bean + public TitanChatBedrockApi titanApi() { + return new TitanChatBedrockApi(TitanChatModel.TITAN_TEXT_EXPRESS_V1.id(), + EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper()); + } + + @Bean + public BedrockTitanChatClient titanChatClient(TitanChatBedrockApi titanApi) { + return new BedrockTitanChatClient(titanApi); + } + + } + +} diff --git a/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApiIT.java b/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApiIT.java index fcec41ed4..dacb7fd66 100644 --- a/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApiIT.java +++ b/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApiIT.java @@ -24,7 +24,7 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import reactor.core.publisher.Flux; import software.amazon.awssdk.regions.Region; -import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatCompletionModel; +import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatModel; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatRequest; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatResponse; import org.springframework.ai.bedrock.titan.api.TitanChatBedrockApi.TitanChatResponseChunk; @@ -38,7 +38,7 @@ import static org.assertj.core.api.Assertions.assertThat; @EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") public class TitanChatBedrockApiIT { - TitanChatBedrockApi titanBedrockApi = new TitanChatBedrockApi(TitanChatCompletionModel.TITAN_TEXT_EXPRESS_V1.id(), + TitanChatBedrockApi titanBedrockApi = new TitanChatBedrockApi(TitanChatModel.TITAN_TEXT_EXPRESS_V1.id(), Region.EU_CENTRAL_1.id()); TitanChatRequest titanChatRequest = TitanChatRequest.builder("Give me the names of 3 famous pirates?") diff --git a/spring-ai-bedrock/src/test/resources/doc/Bedrock Titan Chat API.jpg b/spring-ai-bedrock/src/test/resources/doc/Bedrock Titan Chat API.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0e5d6437d51855165723bd00a5a46c78d5a12b7c GIT binary patch literal 425837 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