diff --git a/README.md b/README.md index 1800788c8..3c58df17a 100644 --- a/README.md +++ b/README.md @@ -28,19 +28,18 @@ Here is an example of existing code before the change ```java @RestController -public class OldSimpleAiController { - - private final ChatClient chatClient; - - @Autowired - public OldSimpleAiController(ChatClient chatClient) { - this.chatClient = chatClient; - } - - @GetMapping("/ai/simple") - public Map completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { - return Map.of("generation", chatClient.call(message)); - } +public class OldSimpleAiController { + + private final ChatClient chatClient; + + public OldSimpleAiController(ChatClient chatClient) { + this.chatClient = chatClient; + } + + @GetMapping("/ai/simple") + Map completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + return Map.of("generation", chatClient.call(message)); + } } ``` @@ -49,18 +48,17 @@ Now after the changes this will be ```java @RestController public class SimpleAiController { - - private final ChatModel chatModel; - - @Autowired - public SimpleAiController(ChatModel chatModel) { - this.chatModel = chatModel; - } - - @GetMapping("/ai/simple") - public Map completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { - return Map.of("generation", chatModel.call(message)); - } + + private final ChatModel chatModel; + + public SimpleAiController(ChatModel chatModel) { + this.chatModel = chatModel; + } + + @GetMapping("/ai/simple") + Map completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + return Map.of("generation", chatModel.call(message)); + } } ``` @@ -79,17 +77,16 @@ Here is an example of existing code before the change ```java @RestController -public class OldSimpleAiController { - - private final ChatClient chatClient; - - @Autowired - public OldSimpleAiController(ChatClient chatClient) { - this.chatClient = chatClient; +class OldSimpleAiController { + + ChatClient chatClient; + + OldSimpleAiController(ChatClient chatClient) { + this.chatClient = chatClient; } @GetMapping("/ai/simple") - public Map completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + Map completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { return Map.of( "generation", chatClient.call(message) @@ -103,37 +100,24 @@ Now after the changes this will be ```java @RestController -public class SimpleAiController { +class SimpleAiController { - private final ChatClient chatClient; - - @Autowired - public SimpleAiController(ChatClient chatClient) { - this.chatClient = chatClient; - } - - @GetMapping("/ai/simple") - public Map completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { - return Map.of( + private final ChatClient.Builder builder; + + SimpleAiController(ChatClient.Builder builder) { + this.builder = builder; + } + + @GetMapping("/ai/simple") + Map completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + return Map.of( "generation", chatClient.prompt().user(message).call().content() ); - } + } } ``` -and in your `@Configuration` class you need to define an `@Bean` as shown below - -```java -@Configuration -public class ApplicationConfiguration { - - @Bean - ChatClient chatClient(ChatModel chatModel) { - return ChatClient.builder(chatModel).build(); - } -} -``` NOTE: The `ChatModel` instance is made available to you through autoconfiguration. diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClientCustomizer.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClientCustomizer.java new file mode 100644 index 000000000..932cd1498 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClientCustomizer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.client; + +import org.springframework.ai.chat.ChatClient; + +/** + * Callback interface that can be used to customize a {@link ChatClient.Builder + * ChatClient.Builder}. + * + * @author Christian Tzolov + * @author Mark Pollack + * @author Josh Long + * @author Arjen Poutsma + * @since 1.0.0 M1 + */ +@FunctionalInterface +public interface ChatClientCustomizer { + + /** + * Callback to customize a {@link ChatClient.Builder ChatClient.Builder} instance. + * @param chatClientBuilder the client builder to customize + */ + void customize(ChatClient.Builder chatClientBuilder); + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/ChatClientTest.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/ChatClientTest.java similarity index 97% rename from spring-ai-core/src/test/java/org/springframework/ai/chat/ChatClientTest.java rename to spring-ai-core/src/test/java/org/springframework/ai/chat/client/ChatClientTest.java index 093ccbde6..e050a24c7 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/chat/ChatClientTest.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/ChatClientTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat; +package org.springframework.ai.chat.client; import java.net.MalformedURLException; import java.net.URL; @@ -29,6 +29,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.ChatModel; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.StreamingChatModel; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; import org.springframework.ai.chat.messages.UserMessage; diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java new file mode 100644 index 000000000..319e307e4 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.client; + +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.ChatModel; +import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +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.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for {@link ChatClient}. + *

+ * This will produce a {@link ChatClient.Builder ChatClient.Builder} bean with the + * {@code prototype} scope, meaning each injection point will receive a newly cloned + * instance of the builder. + * + * @author Christian Tzolov + * @author Mark Pollack + * @author Josh Long + * @author Arjen Poutsma + * @since 1.0.0 M1 + */ +@AutoConfiguration +@ConditionalOnClass(ChatClient.class) +@EnableConfigurationProperties(ChatClientBuilderProperties.class) +@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class ChatClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + ChatClientBuilderConfigurer chatClientBuilderConfigurer(ObjectProvider customizerProvider) { + ChatClientBuilderConfigurer configurer = new ChatClientBuilderConfigurer(); + configurer.setChatClientCustomizers(customizerProvider.orderedStream().toList()); + return configurer; + } + + @Bean + @Scope("prototype") + @ConditionalOnMissingBean + ChatClient.Builder chatClientBuilder(ChatClientBuilderConfigurer chatClientBuilderConfigurer, ChatModel chatModel) { + ChatClient.Builder builder = ChatClient.builder(chatModel); + return chatClientBuilderConfigurer.configure(builder); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderConfigurer.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderConfigurer.java new file mode 100644 index 000000000..39d57fa6d --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderConfigurer.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.client; + +import java.util.List; + +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.client.ChatClientCustomizer; + +/** + * Builder for configuring a {@link ChatClient.Builder}. + * + * @author Christian Tzolov + * @author Mark Pollack + * @author Josh Long + * @author Arjen Poutsma + * @since 1.0.0 M1 + */ +public class ChatClientBuilderConfigurer { + + private List customizers; + + void setChatClientCustomizers(List customizers) { + this.customizers = customizers; + } + + /** + * Configure the specified {@link ChatClient.Builder}. The builder can be further + * tuned and default settings can be overridden. + * @param builder the {@link ChatClient.Builder} instance to configure + * @return the configured builder + */ + public ChatClient.Builder configure(ChatClient.Builder builder) { + applyCustomizers(builder); + return builder; + } + + private void applyCustomizers(ChatClient.Builder builder) { + if (this.customizers != null) { + for (ChatClientCustomizer customizer : this.customizers) { + customizer.customize(builder); + } + } + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java new file mode 100644 index 000000000..329c29f3e --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.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.autoconfigure.chat.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for the chat client builder. + * + * @author Christian Tzolov + * @author Mark Pollack + * @author Josh Long + * @author Arjen Poutsma + * @since 1.0.0 M1 + */ +@ConfigurationProperties(ChatClientBuilderProperties.CONFIG_PREFIX) +public class ChatClientBuilderProperties { + + public static final String CONFIG_PREFIX = "spring.ai.chat.client"; + + /** + * Enable chat client builder. + */ + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} 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 3ce1ae92c..c744be669 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 @@ -33,3 +33,4 @@ org.springframework.ai.autoconfigure.watsonxai.WatsonxAiAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.elasticsearch.ElasticsearchVectorStoreAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.cassandra.CassandraVectorStoreAutoConfiguration org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration +org.springframework.ai.autoconfigure.chat.client.ChatClientAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfigurationIT.java new file mode 100644 index 000000000..0cee65d78 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfigurationIT.java @@ -0,0 +1,122 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.ai.autoconfigure.chat.client; + +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class ChatClientAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(ChatClientAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, OpenAiAutoConfiguration.class, ChatClientAutoConfiguration.class)); + + @Test + void implicitlyEnabled() { + contextRunner.run(context -> { + assertThat(context.getBeansOfType(ChatClient.Builder.class)).isNotEmpty(); + }); + } + + @Test + void explicitlyEnabled() { + contextRunner.withPropertyValues("spring.ai.chat.client.enabled=true").run(context -> { + assertThat(context.getBeansOfType(ChatClient.Builder.class)).isNotEmpty(); + }); + } + + @Test + void explicitlyDisabled() { + contextRunner.withPropertyValues("spring.ai.chat.client.enabled=false").run(context -> { + assertThat(context.getBeansOfType(ChatClient.Builder.class)).isEmpty(); + }); + } + + @Test + void generate() { + contextRunner.run(context -> { + ChatClient.Builder builder = context.getBean(ChatClient.Builder.class); + + assertThat(builder).isNotNull(); + + ChatClient chatClient = builder.build(); + + String response = chatClient.prompt().user("Hello").call().content(); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void testChatClientCustomizers() { + contextRunner.withUserConfiguration(Config.class).run(context -> { + + ChatClient.Builder builder = context.getBean(ChatClient.Builder.class); + + ChatClient chatClient = builder.build(); + + assertThat(chatClient).isNotNull(); + + ActorsFilms actorsFilms = chatClient.prompt() + .user(u -> u.param("actor", "Tom Hanks")) + .call() + .entity(ActorsFilms.class); + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + }); + } + + record ActorsFilms(String actor, List movies) { + } + + @Configuration + static class Config { + + @Bean + public ChatClientCustomizer chatClientCustomizer() { + return b -> b.defaultSystem("You are a movie expert.") + .defaultUser("Generate the filmography of 5 movies for {actor}."); + } + + } + +}