From 52d15f5dc1acc2a6d884fb0348dc5788971fdea4 Mon Sep 17 00:00:00 2001 From: Pablo Sanchidrian Date: Sun, 25 Feb 2024 18:53:07 +0100 Subject: [PATCH] Add Watsox.AI Chat Client integration - feat: setup watsonx ai api - feat: add watsonx ai model options - feat: setup watsonx chat client - feat: add watsonx records/models - add watsonx-ai module to pom - add watsonx-ai module to bom - feat: add connection properties watsonx - feat: add WatsonxAiAutoConfiguration with api client - feat: add starter watsonx.ai - feat: add generate method in watsonx ai api - feat: add watsonx ai api streaming generation method - feat: add watsonx message to prompt converter util - feat: implement call and stream mehtod - feat: add watsonx ai runtime hints - fix: filter null fields - feat: watsonx options tests - feat: add test dependencies - feat: add runtime hints tests - feat: add watsonx client tests - fix: apply linter - feat: add tests for message to prompt converter - feat: add signature - fix: change deprecated IamAuthenticator - fix: do not keep baseUrl in a class variable - fix: webClient request - feat: add default base url to autoconfigure - feat: add watsonx ai integration docs - fix: model options json - feat: add watsonx-ai spring boot starter - feat: enable watsonx api on watsonx chat client - fix: remove condition - feat: add watsonx autoconfigure import - feat: add watsonx module resource aot import - feat: add pom for watsonx ai module Additional pre-merge adjustments: - Rename all WatsonxAIXxx classes to WatsonxAiXxx. - Rename WatsonxChatClient to WatsonxAiChatClient. - Move WatsonxAiChatOptions out of the API. - Implement a Builder for WatsonxAiChatOptions (replace the inline withXxx code). - Add a WatsonxAiChatOptions field to WatsonxAiChatClient as default options. Later, it is also set by the auto-configuration properties. - Implement merging logic for default vs runtime options in WatsonxAiChatClient. - In Auto-config, add WatsonxAiChatProperties with enabled and options fields. Options are passed to the client. - Update the adoc to include the .chat.options properties. - Add the watsonxai doc to the nav.adoc. - Fix license headers and javadocs. - Move dependency versioning to the parent POM. --- models/spring-ai-watsonx-ai/pom.xml | 78 +++++ .../ai/watsonx/WatsonxAiChatClient.java | 139 +++++++++ .../ai/watsonx/WatsonxAiChatOptions.java | 286 ++++++++++++++++++ .../ai/watsonx/aot/WatsonxAIRuntimeHints.java | 47 +++ .../ai/watsonx/api/WatsonxAIApi.java | 123 ++++++++ .../ai/watsonx/api/WatsonxAIRequest.java | 85 ++++++ .../ai/watsonx/api/WatsonxAIResponse.java | 32 ++ .../ai/watsonx/api/WatsonxAIResults.java | 28 ++ .../utils/MessageToPromptConverter.java | 82 +++++ .../resources/META-INF.spring/aot.factories | 2 + .../ai/watsonx/WatsonxAiChatClientTest.java | 230 ++++++++++++++ .../aot/WatsonxAIRuntimeHintsTest.java | 54 ++++ .../watsonx/api/WatsonxAiChatOptionTest.java | 66 ++++ .../utils/MessageToPromptConverterTest.java | 112 +++++++ pom.xml | 3 + spring-ai-bom/pom.xml | 12 + .../src/main/antora/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/api/chat/watsonx-ai-chat.adoc | 148 +++++++++ spring-ai-spring-boot-autoconfigure/pom.xml | 8 + .../watsonxai/WatsonxAIAutoConfiguration.java | 57 ++++ .../WatsonxAIConnectionProperties.java | 82 +++++ .../watsonxai/WatsonxAiChatProperties.java | 68 +++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../WatsonxAIAutoConfigurationTests.java | 49 +++ .../spring-ai-starter-watsonx-ai/pom.xml | 42 +++ 25 files changed, 1835 insertions(+) create mode 100644 models/spring-ai-watsonx-ai/pom.xml create mode 100644 models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatClient.java create mode 100644 models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatOptions.java create mode 100644 models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/aot/WatsonxAIRuntimeHints.java create mode 100644 models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIApi.java create mode 100644 models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIRequest.java create mode 100644 models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIResponse.java create mode 100644 models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIResults.java create mode 100644 models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/utils/MessageToPromptConverter.java create mode 100644 models/spring-ai-watsonx-ai/src/main/resources/META-INF.spring/aot.factories create mode 100644 models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/WatsonxAiChatClientTest.java create mode 100644 models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/aot/WatsonxAIRuntimeHintsTest.java create mode 100644 models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/api/WatsonxAiChatOptionTest.java create mode 100644 models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/utils/MessageToPromptConverterTest.java create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/watsonx-ai-chat.adoc create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIConnectionProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiChatProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIAutoConfigurationTests.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai/pom.xml diff --git a/models/spring-ai-watsonx-ai/pom.xml b/models/spring-ai-watsonx-ai/pom.xml new file mode 100644 index 000000000..8cd07c4e2 --- /dev/null +++ b/models/spring-ai-watsonx-ai/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + + spring-ai-watsonx-ai + + + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot + + + + org.springframework.ai + spring-ai-core + ${project.parent.version} + + + + org.springframework.ai + spring-ai-retry + ${project.parent.version} + + + + org.springframework + spring-webflux + + + + org.springframework.boot + spring-boot-starter-logging + + + + com.ibm.cloud + sdk-core + ${ibm.sdk.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + io.projectreactor + reactor-test + test + 3.6.2 + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + + diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatClient.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatClient.java new file mode 100644 index 000000000..6ec0c5072 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatClient.java @@ -0,0 +1,139 @@ +/* + * 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.watsonx; + +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Flux; + +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.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.watsonx.api.WatsonxAiApi; +import org.springframework.ai.watsonx.api.WatsonxAiRequest; +import org.springframework.ai.watsonx.api.WatsonxAiResponse; +import org.springframework.ai.watsonx.utils.MessageToPromptConverter; +import org.springframework.util.Assert; + +/** + * {@link ChatClient} implementation for {@literal watsonx.ai}. + * + * watsonx.ai allows developers to use large language models within a SaaS service. It + * supports multiple open-source models as well as IBM created models + * [watsonx.ai](https://www.ibm.com/products/watsonx-ai). Please refer to the watsonx.ai + * models for the most up-to-date information about the available models. + * + * @author Pablo Sanchidrian Herrera + * @author John Jario Moreno Rojas + * @author Christian Tzolov + * @since 1.0.0 + */ +public class WatsonxAiChatClient implements ChatClient, StreamingChatClient { + + private final WatsonxAiApi watsonxAiApi; + + private final WatsonxAiChatOptions defaultOptions; + + public WatsonxAiChatClient(WatsonxAiApi watsonxAiApi) { + this(watsonxAiApi, + WatsonxAiChatOptions.builder() + .withTemperature(0.7f) + .withTopP(1.0f) + .withTopK(50) + .withDecodingMethod("greedy") + .withMaxNewTokens(20) + .withMinNewTokens(0) + .withRepetitionPenalty(1.0f) + .build()); + } + + public WatsonxAiChatClient(WatsonxAiApi watsonxAiApi, WatsonxAiChatOptions defaultOptions) { + Assert.notNull(watsonxAiApi, "watsonxAiApi cannot be null"); + Assert.notNull(defaultOptions, "defaultOptions cannot be null"); + this.watsonxAiApi = watsonxAiApi; + this.defaultOptions = defaultOptions; + } + + @Override + public ChatResponse call(Prompt prompt) { + + WatsonxAiRequest request = request(prompt); + + WatsonxAiResponse response = this.watsonxAiApi.generate(request).getBody(); + var generator = new Generation(response.results().get(0).generatedText()); + + generator = generator.withGenerationMetadata( + ChatGenerationMetadata.from(response.results().get(0).stopReason(), response.system())); + + return new ChatResponse(List.of(generator)); + } + + @Override + public Flux stream(Prompt prompt) { + + WatsonxAiRequest request = request(prompt); + + Flux response = this.watsonxAiApi.generateStreaming(request); + + return response.map(chunk -> { + Generation generation = new Generation(chunk.results().get(0).generatedText()); + if (chunk.system() != null) { + generation = generation.withGenerationMetadata( + ChatGenerationMetadata.from(chunk.results().get(0).stopReason(), chunk.system())); + } + return new ChatResponse(List.of(generation)); + }); + } + + public WatsonxAiRequest request(Prompt prompt) { + + WatsonxAiChatOptions options = WatsonxAiChatOptions.builder().build(); + + if (this.defaultOptions != null) { + options = ModelOptionsUtils.merge(options, this.defaultOptions, WatsonxAiChatOptions.class); + } + + if (prompt.getOptions() != null) { + if (prompt.getOptions() instanceof ChatOptions runtimeOptions) { + var updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, ChatOptions.class, + WatsonxAiChatOptions.class); + + options = ModelOptionsUtils.merge(updatedRuntimeOptions, options, WatsonxAiChatOptions.class); + } + else { + throw new IllegalArgumentException("Prompt options are not of type ChatOptions: " + + prompt.getOptions().getClass().getSimpleName()); + } + } + + Map parameters = options.toMap(); + + final String convertedPrompt = MessageToPromptConverter.create() + .withAssistantPrompt("") + .withHumanPrompt("") + .toPrompt(prompt.getInstructions()); + + return WatsonxAiRequest.builder(convertedPrompt).withParameters(parameters).build(); + } + +} \ No newline at end of file diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatOptions.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatOptions.java new file mode 100644 index 000000000..149435049 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatOptions.java @@ -0,0 +1,286 @@ +/* + * 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.watsonx; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.ai.chat.prompt.ChatOptions; + +/** + * Helper class for creating watsonx.ai options. + * + * @author Pablo Sanchidrian Herrera + * @author John Jairo Moreno Rojas + * @since 1.0.0 + * @see watsonx.ai + * valid Parameters and values + */ +// @formatter:off +public class WatsonxAiChatOptions implements ChatOptions { + + /** + * The temperature of the model. Increasing the temperature will + * make the model answer more creatively. (Default: 0.7) + */ + @JsonProperty("temperature") private Float temperature; + + /** + * Works together with top-k. A higher value (e.g., 0.95) will lead to + * more diverse text, while a lower value (e.g., 0.2) will generate more focused and + * conservative text. (Default: 1.0) + */ + @JsonProperty("top_p") private Float topP; + + /** + * Reduces the probability of generating nonsense. A higher value (e.g. + * 100) will give more diverse answers, while a lower value (e.g. 10) will be more + * conservative. (Default: 50) + */ + @JsonProperty("top_k") private Integer topK; + + /** + * Decoding is the process that a model uses to choose the tokens in the generated output. + * Choose one of the following decoding options: + * + * Greedy: Selects the token with the highest probability at each step of the decoding process. + * Greedy decoding produces output that closely matches the most common language in the model's pretraining + * data and in your prompt text, which is desirable in less creative or fact-based use cases. A weakness of + * greedy decoding is that it can cause repetitive loops in the generated output. + * + * Sampling decoding: Offers more variability in how tokens are selected. + * With sampling decoding, the model samples tokens, meaning the model chooses a subset of tokens, + * and then one token is chosen randomly from this subset to be added to the output text. Sampling adds + * variability and randomness to the decoding process, which can be desirable in creative use cases. + * However, with greater variability comes a greater risk of incorrect or nonsensical output. + * (Default: greedy) + */ + @JsonProperty("decoding_method") private String decodingMethod; + + /** + * Sets the limit of tokens that the LLM follow. (Default: 20) + */ + @JsonProperty("max_new_tokens") private Integer maxNewTokens; + + /** + * Sets how many tokens must the LLM generate. (Default: 0) + */ + @JsonProperty("min_new_tokens") private Integer minNewTokens = 0; + + /** + * Sets when the LLM should stop. + * (e.g., ["\n\n\n"]) then when the LLM generates three consecutive line breaks it will terminate. + * Stop sequences are ignored until after the number of tokens that are specified in the Min tokens parameter are generated. + */ + @JsonProperty("stop_sequences") private List stopSequences = List.of(); + + /** + * Sets how strongly to penalize repetitions. A higher value + * (e.g., 1.8) will penalize repetitions more strongly, while a lower value (e.g., + * 1.1) will be more lenient. (Default: 1.0) + */ + @JsonProperty("repetition_penalty") private Float repetitionPenalty; + + /** + * Produce repeatable results, set the same random seed value every time. (Default: randomly generated) + */ + @JsonProperty("random_seed") private Integer randomSeed; + + /** + * Model is the identifier of the LLM Model to be used + */ + @JsonProperty("model") private String model; + + + public Float getTemperature() { + return temperature; + } + + public void setTemperature(Float temperature) { + this.temperature = temperature; + } + + public Float getTopP() { + return topP; + } + + public void setTopP(Float topP) { + this.topP = topP; + } + + public Integer getTopK() { + return topK; + } + + public void setTopK(Integer topK) { + this.topK = topK; + } + + public String getDecodingMethod() { + return decodingMethod; + } + + public void setDecodingMethod(String decodingMethod) { + this.decodingMethod = decodingMethod; + } + + public Integer getMaxNewTokens() { + return maxNewTokens; + } + + public void setMaxNewTokens(Integer maxNewTokens) { + this.maxNewTokens = maxNewTokens; + } + + public Integer getMinNewTokens() { + return minNewTokens; + } + + public void setMinNewTokens(Integer minNewTokens) { + this.minNewTokens = minNewTokens; + } + + public List getStopSequences() { + return stopSequences; + } + + public void setStopSequences(List stopSequences) { + this.stopSequences = stopSequences; + } + + public Float getRepetitionPenalty() { + return repetitionPenalty; + } + + public void setRepetitionPenalty(Float repetitionPenalty) { + this.repetitionPenalty = repetitionPenalty; + } + + public Integer getRandomSeed() { + return randomSeed; + } + + public void setRandomSeed(Integer randomSeed) { + this.randomSeed = randomSeed; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + WatsonxAiChatOptions options = new WatsonxAiChatOptions(); + + 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 Builder withDecodingMethod(String decodingMethod) { + this.options.decodingMethod = decodingMethod; + return this; + } + + public Builder withMaxNewTokens(Integer maxNewTokens) { + this.options.maxNewTokens = maxNewTokens; + return this; + } + + public Builder withMinNewTokens(Integer minNewTokens) { + this.options.minNewTokens = minNewTokens; + return this; + } + + public Builder withStopSequences(List stopSequences) { + this.options.stopSequences = stopSequences; + return this; + } + + public Builder withRepetitionPenalty(Float repetitionPenalty) { + this.options.repetitionPenalty = repetitionPenalty; + return this; + } + + public Builder withRandomSeed(Integer randomSeed) { + this.options.randomSeed = randomSeed; + return this; + } + + public Builder withModel(String model) { + this.options.model = model; + return this; + } + + public WatsonxAiChatOptions build() { + return this.options; + } + } + + /** + * Convert the {@link WatsonxAiChatOptions} object to a {@link Map} of key/value pairs. + * @return The {@link Map} of key/value pairs. + */ + public Map toMap() { + try { + var json = new ObjectMapper().writeValueAsString(this); + return new ObjectMapper().readValue(json, new TypeReference>() { + }); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + /** + * Filter out the non supported fields from the options. + * @param options The options to filter. + * @return The filtered options. + */ + public static Map filterNonSupportedFields(Map options) { + return options.entrySet().stream() + .filter(e -> !e.getKey().equals("model")) + .filter(e -> e.getValue() != null) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + +} +// @formatter:on \ No newline at end of file diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/aot/WatsonxAIRuntimeHints.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/aot/WatsonxAIRuntimeHints.java new file mode 100644 index 000000000..c76470a7a --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/aot/WatsonxAIRuntimeHints.java @@ -0,0 +1,47 @@ +/* + * 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.watsonx.aot; + +import org.springframework.ai.watsonx.WatsonxAiChatOptions; +import org.springframework.ai.watsonx.api.WatsonxAiApi; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage; + +/** + * The WatsonxAiRuntimeHints class is responsible for registering runtime hints for + * Watsonx AI API classes. + * + * @author Pablo Sanchidrian Herrera + * @author John Jario Moreno Rojas + * @since 1.0.0 + */ +public class WatsonxAiRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + var mcs = MemberCategory.values(); + for (var tr : findJsonAnnotatedClassesInPackage(WatsonxAiApi.class)) + hints.reflection().registerType(tr, mcs); + + for (var tr : findJsonAnnotatedClassesInPackage(WatsonxAiChatOptions.class)) + hints.reflection().registerType(tr, mcs); + + } + +} diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIApi.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIApi.java new file mode 100644 index 000000000..18ae0c4b0 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIApi.java @@ -0,0 +1,123 @@ +/* + * 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.watsonx.api; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.ibm.cloud.sdk.core.security.IamAuthenticator; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; + +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.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Java Client for the watsonx.ai API. https://www.ibm.com/products/watsonx-ai + * + * @author John Jairo Moreno Rojas + * @author Pablo Sanchidrian Herrera + * @since 1.0.0 + */ +// @formatter:off +public class WatsonxAiApi { + + private static final Log logger = LogFactory.getLog(WatsonxAiApi.class); + public static final String WATSONX_REQUEST_CANNOT_BE_NULL = "Watsonx Request cannot be null"; + private final RestClient restClient; + private final WebClient webClient; + private final IamAuthenticator iamAuthenticator; + private final String streamEndpoint; + private final String textEndpoint; + private final String projectId; + + /** + * Create a new chat api. + * @param baseUrl api base URL. + * @param streamEndpoint streaming generation. + * @param textEndpoint text generation. + * @param projectId watsonx.ai project identifier. + * @param IAMToken IBM Cloud IAM token. + * @param restClientBuilder rest client builder. + */ + public WatsonxAiApi( + String baseUrl, + String streamEndpoint, + String textEndpoint, + String projectId, + String IAMToken, + RestClient.Builder restClientBuilder + ) { + this.streamEndpoint = streamEndpoint; + this.textEndpoint = textEndpoint; + this.projectId = projectId; + this.iamAuthenticator = IamAuthenticator.fromConfiguration(Map.of("APIKEY", IAMToken)); + + Consumer defaultHeaders = headers -> { + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + }; + + this.restClient = restClientBuilder.baseUrl(baseUrl) + .defaultStatusHandler(RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER) + .defaultHeaders(defaultHeaders) + .build(); + + this.webClient = WebClient.builder().baseUrl(baseUrl) + .defaultHeaders(defaultHeaders) + .build(); + } + + public ResponseEntity generate(WatsonxAiRequest watsonxAiRequest) { + Assert.notNull(watsonxAiRequest, WATSONX_REQUEST_CANNOT_BE_NULL); + + String bearer = this.iamAuthenticator.requestToken().getAccessToken(); + + return this.restClient.post() + .uri(this.textEndpoint) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + bearer) + .body(watsonxAiRequest.withProjectId(projectId)) + .retrieve() + .toEntity(WatsonxAiResponse.class); + } + + public Flux generateStreaming(WatsonxAiRequest watsonxAiRequest) { + Assert.notNull(watsonxAiRequest, WATSONX_REQUEST_CANNOT_BE_NULL); + + String bearer = this.iamAuthenticator.requestToken().getAccessToken(); + + return this.webClient.post() + .uri(this.streamEndpoint) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + bearer) + .bodyValue(watsonxAiRequest.withProjectId(this.projectId)) + .retrieve() + .bodyToFlux(WatsonxAiResponse.class) + .handle((data, sink) -> { + if (logger.isTraceEnabled()) { + logger.trace(data); + } + sink.next(data); + }); + } + +} diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIRequest.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIRequest.java new file mode 100644 index 000000000..7f67df726 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIRequest.java @@ -0,0 +1,85 @@ +/* + * 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.watsonx.api; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.ai.watsonx.WatsonxAiChatOptions; + +// @formatter:off +@JsonInclude(JsonInclude.Include.NON_NULL) +public class WatsonxAiRequest { + + @JsonProperty("input") + private String input; + @JsonProperty("parameters") + private Map parameters; + @JsonProperty("model_id") + private String modelId = ""; + @JsonProperty("project_id") + private String projectId = ""; + + private WatsonxAiRequest(String input, Map parameters, String modelId, String projectId) { + this.input = input; + this.parameters = parameters; + this.modelId = modelId; + this.projectId = projectId; + } + + public WatsonxAiRequest withModelId(String modelId) { + this.modelId = modelId; + return this; + } + + public WatsonxAiRequest withProjectId(String projectId) { + this.projectId = projectId; + return this; + } + + public String getInput() { return input; } + + public Map getParameters() { return parameters; } + + public String getModelId() { return modelId; } + + + public static Builder builder(String input) { return new Builder(input); } + + public static class Builder { + private final String input; + private Map parameters; + private String model = ""; + + public Builder(String input) { + this.input = input; + } + + public Builder withParameters(Map parameters) { + this.model = parameters.get("model").toString(); + this.parameters = WatsonxAiChatOptions.filterNonSupportedFields(parameters); + return this; + } + + public WatsonxAiRequest build() { + return new WatsonxAiRequest(input, parameters, model, ""); + } + + } + +} \ No newline at end of file diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIResponse.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIResponse.java new file mode 100644 index 000000000..dd7766268 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIResponse.java @@ -0,0 +1,32 @@ +/* + * 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.watsonx.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +// @formatter:off +@JsonInclude(JsonInclude.Include.NON_NULL) +public record WatsonxAiResponse( + @JsonProperty("model_id") String modelId, + @JsonProperty("created_at") Date createdAt, + @JsonProperty("results") List results, + @JsonProperty("system") Map system +) {} diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIResults.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIResults.java new file mode 100644 index 000000000..a0d28b900 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAIResults.java @@ -0,0 +1,28 @@ +/* + * 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.watsonx.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +// @formatter:off +@JsonInclude(JsonInclude.Include.NON_NULL) +public record WatsonxAiResults( + @JsonProperty("generated_text") String generatedText, + @JsonProperty("generated_token_count") Integer generatedTokenCount, + @JsonProperty("input_token_count") Integer inputTokenCount, + @JsonProperty("stop_reason") String stopReason +) { } diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/utils/MessageToPromptConverter.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/utils/MessageToPromptConverter.java new file mode 100644 index 000000000..227fd0fc3 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/utils/MessageToPromptConverter.java @@ -0,0 +1,82 @@ +/* + * 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.watsonx.utils; + +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; + +import java.util.List; +import java.util.stream.Collectors; + +// @formatter:off +public class MessageToPromptConverter { + + private static final String HUMAN_PROMPT = "Human: "; + private static final String ASSISTANT_PROMPT = "Assistant: "; + public static final String TOOL_EXECUTION_NOT_SUPPORTED_FOR_WAI_MODELS = "Tool execution results are not supported for watsonx.ai models"; + private String humanPrompt = HUMAN_PROMPT; + private String assistantPrompt = ASSISTANT_PROMPT; + + private MessageToPromptConverter() { + } + + public static MessageToPromptConverter create() { + return new MessageToPromptConverter(); + } + + public MessageToPromptConverter withHumanPrompt(String humanPrompt) { + this.humanPrompt = humanPrompt; + return this; + } + + public MessageToPromptConverter withAssistantPrompt(String assistantPrompt) { + this.assistantPrompt = assistantPrompt; + return this; + } + + public String toPrompt(List messages) { + + final String systemMessages = messages.stream() + .filter(message -> message.getMessageType() == MessageType.SYSTEM) + .map(Message::getContent) + .collect(Collectors.joining("\n")); + + final String userMessages = messages.stream() + .filter(message -> message.getMessageType() == MessageType.USER + || message.getMessageType() == MessageType.ASSISTANT) + .map(this::messageToString) + .collect(Collectors.joining("\n")); + + return String.format("%s%n%n%s%n%s", systemMessages, userMessages, assistantPrompt).trim(); + } + + protected String messageToString(Message message) { + switch (message.getMessageType()) { + case SYSTEM: + return message.getContent(); + case USER: + return humanPrompt + message.getContent(); + case ASSISTANT: + return assistantPrompt + message.getContent(); + case FUNCTION: + throw new IllegalArgumentException(TOOL_EXECUTION_NOT_SUPPORTED_FOR_WAI_MODELS); + } + + throw new IllegalArgumentException("Unknown message type: " + message.getMessageType()); + } + // @formatter:on + +} \ No newline at end of file diff --git a/models/spring-ai-watsonx-ai/src/main/resources/META-INF.spring/aot.factories b/models/spring-ai-watsonx-ai/src/main/resources/META-INF.spring/aot.factories new file mode 100644 index 000000000..fe0f1263e --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/main/resources/META-INF.spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + org.springframework.ai.watsonx.aot.WatsonxAiRuntimeHints diff --git a/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/WatsonxAiChatClientTest.java b/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/WatsonxAiChatClientTest.java new file mode 100644 index 000000000..c74afa427 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/WatsonxAiChatClientTest.java @@ -0,0 +1,230 @@ +/* + * 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.watsonx; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.Assert; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.prompt.ChatOptionsBuilder; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.watsonx.api.WatsonxAiApi; +import org.springframework.ai.watsonx.api.WatsonxAiRequest; +import org.springframework.ai.watsonx.api.WatsonxAiResponse; +import org.springframework.ai.watsonx.api.WatsonxAiResults; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author Pablo Sanchidrian Herrera + * @author John Jairo Moreno Rojas + */ +public class WatsonxAiChatClientTest { + + WatsonxAiChatClient chatClient = new WatsonxAiChatClient(mock(WatsonxAiApi.class)); + + @Test + public void testCreateRequestWithNoModelId() { + var options = ChatOptionsBuilder.builder().withTemperature(0.9f).withTopK(100).withTopP(0.6f).build(); + + Prompt prompt = new Prompt("Test message", options); + + Exception exception = Assert.assertThrows(IllegalArgumentException.class, () -> { + WatsonxAiRequest request = chatClient.request(prompt); + }); + } + + @Test + public void testCreateRequestSuccessfullyWithDefaultParams() { + + String msg = "Test message"; + + WatsonxAiChatOptions modelOptions = WatsonxAiChatOptions.builder() + .withModel("meta-llama/llama-2-70b-chat") + .build(); + Prompt prompt = new Prompt(msg, modelOptions); + + WatsonxAiRequest request = chatClient.request(prompt); + + Assert.assertEquals(request.getModelId(), "meta-llama/llama-2-70b-chat"); + assertThat(request.getParameters().get("decoding_method")).isEqualTo("greedy"); + assertThat(request.getParameters().get("temperature")).isEqualTo(0.7); + assertThat(request.getParameters().get("top_p")).isEqualTo(1.0); + assertThat(request.getParameters().get("top_k")).isEqualTo(50); + assertThat(request.getParameters().get("max_new_tokens")).isEqualTo(20); + assertThat(request.getParameters().get("min_new_tokens")).isEqualTo(0); + assertThat(request.getParameters().get("stop_sequences")).isInstanceOf(List.class); + Assert.assertEquals(request.getParameters().get("stop_sequences"), List.of()); + assertThat(request.getParameters().get("random_seed")).isNull(); + } + + @Test + public void testCreateRequestSuccessfullyWithNonDefaultParams() { + + String msg = "Test message"; + + WatsonxAiChatOptions modelOptions = WatsonxAiChatOptions.builder() + .withModel("meta-llama/llama-2-70b-chat") + .withDecodingMethod("sample") + .withTemperature(0.1f) + .withTopP(0.2f) + .withTopK(10) + .withMaxNewTokens(30) + .withMinNewTokens(10) + .withRepetitionPenalty(1.4f) + .withStopSequences(List.of("\n\n\n")) + .withRandomSeed(4) + .build(); + + Prompt prompt = new Prompt(msg, modelOptions); + + WatsonxAiRequest request = chatClient.request(prompt); + + Assert.assertEquals(request.getModelId(), "meta-llama/llama-2-70b-chat"); + assertThat(request.getParameters().get("decoding_method")).isEqualTo("sample"); + assertThat(request.getParameters().get("temperature")).isEqualTo(0.1); + assertThat(request.getParameters().get("top_p")).isEqualTo(0.2); + assertThat(request.getParameters().get("top_k")).isEqualTo(10); + assertThat(request.getParameters().get("max_new_tokens")).isEqualTo(30); + assertThat(request.getParameters().get("min_new_tokens")).isEqualTo(10); + assertThat(request.getParameters().get("stop_sequences")).isInstanceOf(List.class); + Assert.assertEquals(request.getParameters().get("stop_sequences"), List.of("\n\n\n")); + assertThat(request.getParameters().get("random_seed")).isEqualTo(4); + } + + @Test + public void testCreateRequestSuccessfullyWithChatDisabled() { + + String msg = "Test message"; + + WatsonxAiChatOptions modelOptions = WatsonxAiChatOptions.builder() + .withModel("meta-llama/llama-2-70b-chat") + .withDecodingMethod("sample") + .withTemperature(0.1f) + .withTopP(0.2f) + .withTopK(10) + .withMaxNewTokens(30) + .withMinNewTokens(10) + .withRepetitionPenalty(1.4f) + .withStopSequences(List.of("\n\n\n")) + .withRandomSeed(4) + .build(); + + Prompt prompt = new Prompt(msg, modelOptions); + + WatsonxAiRequest request = chatClient.request(prompt); + + Assert.assertEquals(request.getModelId(), "meta-llama/llama-2-70b-chat"); + assertThat(request.getInput()).isEqualTo(msg); + assertThat(request.getParameters().get("decoding_method")).isEqualTo("sample"); + assertThat(request.getParameters().get("temperature")).isEqualTo(0.1); + assertThat(request.getParameters().get("top_p")).isEqualTo(0.2); + assertThat(request.getParameters().get("top_k")).isEqualTo(10); + assertThat(request.getParameters().get("max_new_tokens")).isEqualTo(30); + assertThat(request.getParameters().get("min_new_tokens")).isEqualTo(10); + assertThat(request.getParameters().get("stop_sequences")).isInstanceOf(List.class); + Assert.assertEquals(request.getParameters().get("stop_sequences"), List.of("\n\n\n")); + assertThat(request.getParameters().get("random_seed")).isEqualTo(4); + } + + @Test + public void testCallMethod() { + WatsonxAiApi mockChatApi = mock(WatsonxAiApi.class); + WatsonxAiChatClient client = new WatsonxAiChatClient(mockChatApi); + + Prompt prompt = new Prompt(List.of(new SystemMessage("Your prompt here")), + WatsonxAiChatOptions.builder().withModel("google/flan-ul2").build()); + + WatsonxAiChatOptions parameters = WatsonxAiChatOptions.builder().withModel("google/flan-ul2").build(); + + WatsonxAiResults fakeResults = new WatsonxAiResults("LLM response", 4, 3, "max_tokens"); + + WatsonxAiResponse fakeResponse = new WatsonxAiResponse("google/flan-ul2", new Date(), List.of(fakeResults), + Map.of("warnings", List.of(Map.of("message", "the message", "id", "disclaimer_warning")))); + + when(mockChatApi.generate(any(WatsonxAiRequest.class))) + .thenReturn(ResponseEntity.of(Optional.of(fakeResponse))); + + Generation expectedGenerator = new Generation("LLM response") + .withGenerationMetadata(ChatGenerationMetadata.from("max_tokens", + Map.of("warnings", List.of(Map.of("message", "the message", "id", "disclaimer_warning"))))); + + ChatResponse expectedResponse = new ChatResponse(List.of(expectedGenerator)); + ChatResponse response = client.call(prompt); + + Assert.assertEquals(expectedResponse.getResults().size(), response.getResults().size()); + Assert.assertEquals(expectedResponse.getResult().getOutput(), response.getResult().getOutput()); + } + + @Test + public void testStreamMethod() { + WatsonxAiApi mockChatApi = mock(WatsonxAiApi.class); + WatsonxAiChatClient client = new WatsonxAiChatClient(mockChatApi); + + Prompt prompt = new Prompt(List.of(new SystemMessage("Your prompt here")), + WatsonxAiChatOptions.builder().withModel("google/flan-ul2").build()); + + WatsonxAiChatOptions parameters = WatsonxAiChatOptions.builder().withModel("google/flan-ul2").build(); + + WatsonxAiResults fakeResultsFirst = new WatsonxAiResults("LLM resp", 0, 0, "max_tokens"); + WatsonxAiResults fakeResultsSecond = new WatsonxAiResults("onse", 4, 3, "not_finished"); + + WatsonxAiResponse fakeResponseFirst = new WatsonxAiResponse("google/flan-ul2", new Date(), + List.of(fakeResultsFirst), + Map.of("warnings", List.of(Map.of("message", "the message", "id", "disclaimer_warning")))); + WatsonxAiResponse fakeResponseSecond = new WatsonxAiResponse("google/flan-ul2", new Date(), + List.of(fakeResultsSecond), null); + + Flux fakeResponse = Flux.just(fakeResponseFirst, fakeResponseSecond); + when(mockChatApi.generateStreaming(any(WatsonxAiRequest.class))).thenReturn(fakeResponse); + + Generation firstGen = new Generation("LLM resp") + .withGenerationMetadata(ChatGenerationMetadata.from("max_tokens", + Map.of("warnings", List.of(Map.of("message", "the message", "id", "disclaimer_warning"))))); + Generation secondGen = new Generation("onse"); + + Flux response = client.stream(prompt); + + StepVerifier.create(response).assertNext(current -> { + + ChatResponse expected = new ChatResponse(List.of(firstGen)); + + Assert.assertEquals(expected.getResults().size(), current.getResults().size()); + Assert.assertEquals(expected.getResult().getOutput(), current.getResult().getOutput()); + }).assertNext(current -> { + ChatResponse expected = new ChatResponse(List.of(secondGen)); + + Assert.assertEquals(expected.getResults().size(), current.getResults().size()); + Assert.assertEquals(expected.getResult().getOutput(), current.getResult().getOutput()); + }).expectComplete().verify(); + + } + +} diff --git a/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/aot/WatsonxAIRuntimeHintsTest.java b/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/aot/WatsonxAIRuntimeHintsTest.java new file mode 100644 index 000000000..fddaba9e5 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/aot/WatsonxAIRuntimeHintsTest.java @@ -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.watsonx.aot; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.watsonx.WatsonxAiChatOptions; +import org.springframework.ai.watsonx.api.WatsonxAiApi; +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; + +/** + * @author Pablo Sanchidrian Herrera + * @author John Jairo Moreno Rojas + */ +public class WatsonxAiRuntimeHintsTest { + + @Test + void registerHints() { + RuntimeHints runtimeHints = new RuntimeHints(); + WatsonxAiRuntimeHints watsonxAIRuntimeHintsTest = new WatsonxAiRuntimeHints(); + watsonxAIRuntimeHintsTest.registerHints(runtimeHints, null); + + Set jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage(WatsonxAiApi.class); + for (TypeReference jsonAnnotatedClass : jsonAnnotatedClasses) { + assertThat(runtimeHints).matches(reflection().onType(jsonAnnotatedClass)); + } + + jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage(WatsonxAiChatOptions.class); + for (TypeReference jsonAnnotatedClass : jsonAnnotatedClasses) { + assertThat(runtimeHints).matches(reflection().onType(jsonAnnotatedClass)); + } + } + +} diff --git a/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/api/WatsonxAiChatOptionTest.java b/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/api/WatsonxAiChatOptionTest.java new file mode 100644 index 000000000..de5347fa0 --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/api/WatsonxAiChatOptionTest.java @@ -0,0 +1,66 @@ +/* + * 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.watsonx.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; + +import org.springframework.ai.watsonx.WatsonxAiChatOptions; + +import java.util.List; + +/** + * @author Pablo Sanchidrian Herrera + * @author John Jairo Moreno Rojas + */ +public class WatsonxAiChatOptionTest { + + @Test + public void testOptions() { + WatsonxAiChatOptions options = WatsonxAiChatOptions.builder() + .withDecodingMethod("sample") + .withTemperature(1.2f) + .withTopK(20) + .withTopP(0.5f) + .withMaxNewTokens(100) + .withMinNewTokens(20) + .withStopSequences(List.of("\n\n\n")) + .withRepetitionPenalty(1.1f) + .withRandomSeed(4) + .build(); + + var optionsMap = options.toMap(); + + assertThat(optionsMap).containsEntry("decoding_method", "sample"); + assertThat(optionsMap).containsEntry("temperature", 1.2); + assertThat(optionsMap).containsEntry("top_k", 20); + assertThat(optionsMap).containsEntry("top_p", 0.5); + assertThat(optionsMap).containsEntry("max_new_tokens", 100); + assertThat(optionsMap).containsEntry("min_new_tokens", 20); + assertThat(optionsMap).containsEntry("stop_sequences", List.of("\n\n\n")); + assertThat(optionsMap).containsEntry("repetition_penalty", 1.1); + assertThat(optionsMap).containsEntry("random_seed", 4); + } + + @Test + public void testFilterOut() { + WatsonxAiChatOptions options = WatsonxAiChatOptions.builder().withModel("google/flan-ul2").build(); + var mappedOptions = WatsonxAiChatOptions.filterNonSupportedFields(options.toMap()); + assertThat(mappedOptions).doesNotContainEntry("model", "google/flan-ul2"); + } + +} diff --git a/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/utils/MessageToPromptConverterTest.java b/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/utils/MessageToPromptConverterTest.java new file mode 100644 index 000000000..f10d26d7f --- /dev/null +++ b/models/spring-ai-watsonx-ai/src/test/java/org/springframework/ai/watsonx/utils/MessageToPromptConverterTest.java @@ -0,0 +1,112 @@ +/* + * 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.watsonx.utils; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.api.Disabled; +import org.springframework.ai.chat.messages.*; + +import java.util.List; + +/** + * @author Pablo Sanchidrian Herrera + * @author John Jairo Moreno Rojas + */ +public class MessageToPromptConverterTest { + + private MessageToPromptConverter converter; + + @Before + public void setUp() { + converter = MessageToPromptConverter.create().withHumanPrompt("").withAssistantPrompt(""); + } + + @Test + public void testSingleUserMessage() { + Message userMessage = new UserMessage("User message"); + String expected = "User message"; + Assert.assertEquals(expected, converter.messageToString(userMessage)); + } + + @Test + public void testSingleAssistantMessage() { + Message assistantMessage = new AssistantMessage("Assistant message"); + String expected = "Assistant message"; + Assert.assertEquals(expected, converter.messageToString(assistantMessage)); + } + + @Disabled + public void testFunctionMessageType() { + Message functionMessage = new FunctionMessage("Function message"); + Exception exception = Assert.assertThrows(IllegalArgumentException.class, () -> { + converter.messageToString(functionMessage); + }); + } + + @Test + public void testSystemMessageType() { + Message systemMessage = new SystemMessage("System message"); + String expected = "System message"; + Assert.assertEquals(expected, converter.messageToString(systemMessage)); + } + + @Test + public void testCustomHumanPrompt() { + converter.withHumanPrompt("Custom Human: "); + Message userMessage = new UserMessage("User message"); + String expected = "Custom Human: User message"; + Assert.assertEquals(expected, converter.messageToString(userMessage)); + } + + @Test + public void testCustomAssistantPrompt() { + converter.withAssistantPrompt("Custom Assistant: "); + Message assistantMessage = new AssistantMessage("Assistant message"); + String expected = "Custom Assistant: Assistant message"; + Assert.assertEquals(expected, converter.messageToString(assistantMessage)); + } + + @Test + public void testEmptyMessageList() { + String expected = ""; + Assert.assertEquals(expected, converter.toPrompt(List.of())); + } + + @Test + public void testSystemMessageList() { + String msg = "this is a LLM prompt"; + SystemMessage message = new SystemMessage(msg); + Assert.assertEquals(msg, converter.toPrompt(List.of(message))); + } + + @Test + public void testUserMessageList() { + List messages = List.of(new UserMessage("User message")); + String expected = "User message"; + Assert.assertEquals(expected, converter.toPrompt(messages)); + } + + @Disabled + public void testUnsupportedMessageType() { + List messages = List.of(new FunctionMessage("Unsupported message")); + Exception exception = Assert.assertThrows(IllegalArgumentException.class, () -> { + converter.toPrompt(messages); + }); + } + +} diff --git a/pom.xml b/pom.xml index a16eaee71..162c6ff8c 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ models/spring-ai-vertex-ai-palm2 models/spring-ai-vertex-ai-gemini models/spring-ai-anthropic + models/spring-ai-watsonx-ai spring-ai-test spring-ai-spring-boot-autoconfigure spring-ai-spring-boot-starters/spring-ai-starter-openai @@ -67,6 +68,7 @@ spring-ai-spring-boot-testcontainers spring-ai-spring-boot-starters/spring-ai-starter-anthropic vector-stores/spring-ai-elasticsearch-store + spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai @@ -124,6 +126,7 @@ 26.34.0 1.7.1 2.0.5 + 9.20.0 3.25.2 diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 6d72deb07..19c7235de 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -260,12 +260,24 @@ ${project.version} + + org.springframework.ai + spring-ai-watsonx-ai-spring-boot-starter + ${project.version} + + org.springframework.ai spring-ai-pgvector-store-spring-boot-starter ${project.version} + + org.springframework.ai + spring-ai-watsonx-ai + ${project.version} + + org.springframework.ai spring-ai-pinecone-store-spring-boot-starter diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index 848e4fb8a..e104a1aa7 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -23,6 +23,7 @@ *** xref:api/chat/mistralai-chat.adoc[Mistral AI] **** xref:api/chat/functions/mistralai-chat-functions.adoc[Function Calling] *** xref:api/chat/anthropic-chat.adoc[Anthropic 3] +*** xref:api/chat/watsonx-ai-chat.adoc[Watsonx.AI] ** xref:api/embeddings.adoc[] *** xref:api/embeddings/openai-embeddings.adoc[OpenAI] *** xref:api/embeddings/ollama-embeddings.adoc[Ollama] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/watsonx-ai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/watsonx-ai-chat.adoc new file mode 100644 index 000000000..527abbc56 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/watsonx-ai-chat.adoc @@ -0,0 +1,148 @@ += watsonx.ai Chat + +With https://dataplatform.cloud.ibm.com/docs/content/wsj/getting-started/overview-wx.html?context=wx&audience=wdp[watsonx.ai] you can run various Large Language Models (LLMs) locally and generate text from them. +Spring AI supports the watsonx.ai text generation with `WatsonxAiChatClient`. + + +== Prerequisites + +You first need to have a SaaS instance of watsonx.ai (as well as an IBM Cloud account). + +Refer to https://eu-de.dataplatform.cloud.ibm.com/registration/stepone?context=wx&preselect_region=true[free-trial] to try watsonx.ai for free + +TIP: More info. can be found https://www.ibm.com/products/watsonx-ai/info/trial[here] + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the watsonx.ai Chat Client. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source,xml] +---- + + org.springframework.ai + spring-ai-watsonx-ai-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-watsonx-ai-spring-boot-starter' +} +---- + +=== Chat Properties + +==== Connection Properties + +The prefix `spring.ai.watsonx.ai` is used as the property prefix that lets you connect to watsonx.ai. + +[cols="4,3,3"] +|==== +| Property | Description | Default + +| spring.ai.watsonx.ai.base-url | The URL to connect to | https://us-south.ml.cloud.ibm.com +| spring.ai.watsonx.ai.stream-endpoint | The streaming endpoint | generation/stream?version=2023-05-29 +| spring.ai.watsonx.ai.text-endpoint | The text endpoint | generation/text?version=2023-05-29 +| spring.ai.watsonx.ai.project-id | The project ID | - +| spring.ai.watsonx.ai.iam-token | The IBM Cloud account IAM token | - +|==== + +==== Configuration Properties + +The prefix `spring.ai.watsonx.ai.chat` is the property prefix that lets you configure the chat client implementation for Watsonx.AI. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.watsonx.ai.chat.enabled | Enable Watsonx.AI chat client. | true +| spring.ai.watsonx.ai.chat.options.temperature | The temperature of the model. Increasing the temperature will make the model answer more creatively. | 0.7 +| spring.ai.watsonx.ai.chat.options.top-p | Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.2) will generate more focused and conservative text. | 1.0 +| spring.ai.watsonx.ai.chat.options.top-k | Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative. | 50 +| spring.ai.watsonx.ai.chat.options.decoding-method | Decoding is the process that a model uses to choose the tokens in the generated output. | greedy +| spring.ai.watsonx.ai.chat.options.max-new-tokens | Sets the limit of tokens that the LLM follow. | 20 +| spring.ai.watsonx.ai.chat.options.min-new-tokens | Sets how many tokens must the LLM generate. | 0 +| spring.ai.watsonx.ai.chat.options.stop-sequences | Sets when the LLM should stop. (e.g., ["\n\n\n"]) then when the LLM generates three consecutive line breaks it will terminate. Stop sequences are ignored until after the number of tokens that are specified in the Min tokens parameter are generated. | - +| spring.ai.watsonx.ai.chat.options.repetition-penalty | Sets how strongly to penalize repetitions. A higher value (e.g., 1.8) will penalize repetitions more strongly, while a lower value (e.g., 1.1) will be more lenient. | 1.0 +| spring.ai.watsonx.ai.chat.options.random-seed | Produce repeatable results, set the same random seed value every time. | randomly generated +| spring.ai.watsonx.ai.chat.options.model | Model is the identifier of the LLM Model to be used. | ???? +|==== + +== Runtime Options [[chat-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatOptions.java[WatsonxAiChatOptions.java] provides model configurations, such as the model to use, the temperature, the frequency penalty, etc. + +On start-up, the default options can be configured with the `WatsonxAiChatClient(api, options)` constructor or the `spring.ai.watsonxai.chat.options.*` properties. + +At run-time you can override the default options by adding new, request specific, options to the `Prompt` call. +For example to override the default model and temperature for a specific request: + +[source,java] +---- +ChatResponse response = chatClient.call( + new Prompt( + "Generate the names of 5 famous pirates.", + WatsonxAiChatOptions.builder() + .withTemperature(0.4) + .build() + )); +---- + +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatOptions.java[WatsonxAiChatOptions.java] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. + +NOTE: For more information go to https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-model-parameters.html?context=wx[watsonx-parameters-info] + +== Usage example + +[source,java] +---- +public class MyClass { + + private final static String MODEL = "google/flan-ul2"; + private final WatsonxAiChatClient chat; + + @Autowired + MyClass(WatsonxAiChatClient chat) { + this.chat = chat; + } + + public String generate(String userInput) { + + WatsonxAiOptions options = WatsonxAiOptions.create() + .withModel(MODEL) + .withDecodingMethod("sample") + .withRandomSeed(1); + + Prompt prompt = new Prompt(new SystemMessage(userInput), options); + + var results = chat.call(prompt); + + var generatedText = results.getResult().getOutput().getContent(); + + return generatedText; + } + + public String generateStream(String userInput) { + + WatsonxAiOptions options = WatsonxAiOptions.create() + .withModel(MODEL) + .withDecodingMethod("greedy") + .withRandomSeed(2); + + Prompt prompt = new Prompt(new SystemMessage(userInput), options); + + var results = chat.stream(prompt).collectList().block(); // wait till the stream is resolved (completed) + + var generatedText = results.stream() + .map(generation -> generation.getResult().getOutput().getContent()) + .collect(Collectors.joining()); + + return generatedText; + } + +} +---- diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index de6595323..5b7bbc2a9 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -185,6 +185,14 @@ true + + + org.springframework.ai + spring-ai-watsonx-ai + ${project.parent.version} + true + + org.springframework.ai diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIAutoConfiguration.java new file mode 100644 index 000000000..ce651ee17 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIAutoConfiguration.java @@ -0,0 +1,57 @@ +/* + * 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.watsonxai; + +import org.springframework.ai.watsonx.WatsonxAiChatClient; +import org.springframework.ai.watsonx.api.WatsonxAiApi; +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.web.client.RestClient; + +/** + * WatsonX.ai autoconfiguration class. + * + * @author Pablo Sanchidrian Herrera + * @author John Jario Moreno Rojas + * @author Christian Tzolov + * @since 1.0.0 + */ +@AutoConfiguration(after = RestClientAutoConfiguration.class) +@ConditionalOnClass(WatsonxAiApi.class) +@EnableConfigurationProperties({ WatsonxAiConnectionProperties.class, WatsonxAiChatProperties.class }) +@ConditionalOnProperty(prefix = WatsonxAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class WatsonxAiAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public WatsonxAiApi watsonxApi(WatsonxAiConnectionProperties properties, RestClient.Builder restClientBuilder) { + return new WatsonxAiApi(properties.getBaseUrl(), properties.getStreamEndpoint(), properties.getTextEndpoint(), + properties.getProjectId(), properties.getIAMToken(), restClientBuilder); + } + + @Bean + @ConditionalOnMissingBean + public WatsonxAiChatClient watsonxChatClient(WatsonxAiApi watsonxApi, WatsonxAiChatProperties chatProperties) { + return new WatsonxAiChatClient(watsonxApi, chatProperties.getOptions()); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIConnectionProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIConnectionProperties.java new file mode 100644 index 000000000..3e6357c63 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIConnectionProperties.java @@ -0,0 +1,82 @@ +/* + * 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.watsonxai; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * WatsonX.ai connection autoconfiguration properties. + * + * @author Pablo Sanchidrian Herrera + * @author John Jario Moreno Rojas + * @since 1.0.0 + */ +@ConfigurationProperties(WatsonxAiConnectionProperties.CONFIG_PREFIX) +public class WatsonxAiConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.watsonx.ai"; + + private String baseUrl = "https://us-south.ml.cloud.ibm.com/"; + + private String streamEndpoint = "generation/stream?version=2023-05-29"; + + private String textEndpoint = "generation/text?version=2023-05-29"; + + private String projectId; + + private String IAMToken; + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getStreamEndpoint() { + return streamEndpoint; + } + + public void setStreamEndpoint(String streamEndpoint) { + this.streamEndpoint = streamEndpoint; + } + + public String getTextEndpoint() { + return textEndpoint; + } + + public void setTextEndpoint(String textEndpoint) { + this.textEndpoint = textEndpoint; + } + + public String getProjectId() { + return projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getIAMToken() { + return IAMToken; + } + + public void setIAMToken(String IAMToken) { + this.IAMToken = IAMToken; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiChatProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiChatProperties.java new file mode 100644 index 000000000..2f92c3b31 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAiChatProperties.java @@ -0,0 +1,68 @@ +/* + * 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.watsonxai; + +import org.springframework.ai.watsonx.WatsonxAiChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Chat properties for Watsonx.AI Chat. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +@ConfigurationProperties(WatsonxAiChatProperties.CONFIG_PREFIX) +public class WatsonxAiChatProperties { + + public static final String CONFIG_PREFIX = "spring.ai.watsonx.ai.chat"; + + /** + * Enable Watsonx.AI chat client. + */ + private boolean enabled = true; + + /** + * Watsonx AI generative options. + */ + @NestedConfigurationProperty + private WatsonxAiChatOptions options = WatsonxAiChatOptions.builder() + .withTemperature(0.7f) + .withTopP(1.0f) + .withTopK(50) + .withDecodingMethod("greedy") + .withMaxNewTokens(20) + .withMinNewTokens(0) + .withRepetitionPenalty(1.0f) + .build(); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public WatsonxAiChatOptions getOptions() { + return this.options; + } + + public void setOptions(WatsonxAiChatOptions options) { + this.options = options; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 2a5e030ad..418ba7a1a 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -28,3 +28,4 @@ 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 +org.springframework.ai.autoconfigure.watsonxai.WatsonxAiAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIAutoConfigurationTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIAutoConfigurationTests.java new file mode 100644 index 000000000..6a8831cfe --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/watsonxai/WatsonxAIAutoConfigurationTests.java @@ -0,0 +1,49 @@ +/* + * 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.watsonxai; + +import org.junit.Test; +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; + +public class WatsonxAiAutoConfigurationTests { + + @Test + public void propertiesTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.watsonx.ai.base-url=TEST_BASE_URL", + "spring.ai.watsonx.ai.stream-endpoint=generation/stream?version=2023-05-29", + "spring.ai.watsonx.ai.text-endpoint=generation/text?version=2023-05-29", + "spring.ai.watsonx.ai.projectId=1", + "spring.ai.watsonx.ai.IAMToken=123456") + // @formatter:on + .withConfiguration( + AutoConfigurations.of(RestClientAutoConfiguration.class, WatsonxAiAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(WatsonxAiConnectionProperties.class); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getStreamEndpoint()).isEqualTo("generation/stream?version=2023-05-29"); + assertThat(connectionProperties.getTextEndpoint()).isEqualTo("generation/text?version=2023-05-29"); + assertThat(connectionProperties.getProjectId()).isEqualTo("1"); + assertThat(connectionProperties.getIAMToken()).isEqualTo("123456"); + }); + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai/pom.xml new file mode 100644 index 000000000..44dc3af3f --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-watsonx-ai-spring-boot-starter + jar + Spring AI Starter - Watsonx.AI + Spring AI Watsonx AI Auto Configuration + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-watsonx-ai + ${project.parent.version} + + + + \ No newline at end of file