Add ChatClient.Builder auto-configuraiton support

- add ChatClinetCustomizer support.
  - add enable/disable property - enabled by default.
  - add ITs.

* Update README.md
This commit is contained in:
Christian Tzolov
2024-05-23 20:55:20 +02:00
committed by Mark Pollack
parent 5f887b3eb6
commit 0571aac44c
8 changed files with 385 additions and 58 deletions

View File

@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
Map<String, String> 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<String, String> 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<String, String> 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.

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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}.
* <p>
* 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<ChatClientCustomizer> 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);
}
}

View File

@@ -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<ChatClientCustomizer> customizers;
void setChatClientCustomizers(List<ChatClientCustomizer> 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);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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<String> 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}.");
}
}
}