Extend ChatClient API
- add support for custom StructuredOutputConverters usng entity(outputConverterInstance)
- add mutate() method that returns ChatClient.Builder to create a new ChatClient whose
settings are replicated from the ChatClient's default settings.
- add prompt().mutate() method that returns ChatClient.Builder to create a new ChatClient
whose settings are replicated from the current default and prompt settings.
This commit is contained in:
@@ -13,12 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.ai.openai.chat;
|
||||
package org.springframework.ai.openai.chat.client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -34,6 +33,7 @@ import reactor.core.publisher.Flux;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.ai.converter.ListOutputConverter;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.openai.OpenAiTestConfiguration;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
@@ -42,6 +42,7 @@ import org.springframework.ai.openai.testutils.AbstractIT;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.core.convert.support.DefaultConversionService;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.util.MimeTypeUtils;
|
||||
@@ -79,20 +80,21 @@ class OpenAiChatClientIT extends AbstractIT {
|
||||
}
|
||||
|
||||
@Test
|
||||
void listOutputConverter() {
|
||||
void listOutputConverterString() {
|
||||
// @formatter:off
|
||||
Collection<String> collection = ChatClient.builder(chatModel).build().prompt()
|
||||
List<String> collection = ChatClient.builder(chatModel).build().prompt()
|
||||
.user(u -> u.text("List five {subject}")
|
||||
.param("subject", "ice cream flavors"))
|
||||
.call()
|
||||
.entity(new ParameterizedTypeReference<List<String>>() {});
|
||||
// @formatter:on
|
||||
|
||||
logger.info(collection.toString());
|
||||
assertThat(collection).hasSize(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listOutputConverter2() {
|
||||
void listOutputConverterBean() {
|
||||
|
||||
// @formatter:off
|
||||
List<ActorsFilms> actorsFilms = ChatClient.builder(chatModel).build().prompt()
|
||||
@@ -106,6 +108,24 @@ class OpenAiChatClientIT extends AbstractIT {
|
||||
assertThat(actorsFilms).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void customOutputConverter() {
|
||||
|
||||
var toStringListConverter = new ListOutputConverter(new DefaultConversionService());
|
||||
|
||||
// @formatter:off
|
||||
List<String> flavors = ChatClient.builder(chatModel).build().prompt()
|
||||
.user(u -> u.text("List five {subject}")
|
||||
.param("subject", "ice cream flavors"))
|
||||
.call()
|
||||
.entity(toStringListConverter);
|
||||
// @formatter:on
|
||||
|
||||
logger.info("ice cream flavors" + flavors);
|
||||
assertThat(flavors).hasSize(5);
|
||||
assertThat(flavors).contains("Vanilla");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapOutputConverter() {
|
||||
// @formatter:off
|
||||
@@ -196,6 +216,24 @@ class OpenAiChatClientIT extends AbstractIT {
|
||||
assertThat(response).containsAnyOf("15.0", "15");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultFunctionCallTest() {
|
||||
|
||||
// @formatter:off
|
||||
String response = ChatClient.builder(chatModel)
|
||||
.defaultFunction("getCurrentWeather", "Get the weather in location", new MockWeatherService())
|
||||
.defaultUser(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?"))
|
||||
.build()
|
||||
.prompt().call().content();
|
||||
// @formatter:on
|
||||
|
||||
logger.info("Response: {}", response);
|
||||
|
||||
assertThat(response).containsAnyOf("30.0", "30");
|
||||
assertThat(response).containsAnyOf("10.0", "10");
|
||||
assertThat(response).containsAnyOf("15.0", "15");
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamFunctionCallTest() {
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.chat.prompt.PromptTemplate;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.ai.converter.StructuredOutputConverter;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.function.FunctionCallbackWrapper;
|
||||
import org.springframework.ai.model.function.FunctionCallingOptions;
|
||||
@@ -74,6 +75,12 @@ public interface ChatClient {
|
||||
|
||||
ChatClientPromptRequest prompt(Prompt prompt);
|
||||
|
||||
/**
|
||||
* Return a {@link ChatClient.Builder} to create a new {@link ChatClient} whose
|
||||
* settings are replicated from the default {@link ChatClientRequest} of this client.
|
||||
*/
|
||||
Builder mutate();
|
||||
|
||||
interface PromptSpec<T> {
|
||||
|
||||
T text(String text);
|
||||
@@ -223,6 +230,26 @@ public interface ChatClient {
|
||||
|
||||
private final Map<String, Object> systemParams = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Return a {@code ChatClient.Builder} to create a new {@code ChatClient} whose
|
||||
* settings are replicated from this {@code ChatClientRequest}.
|
||||
*/
|
||||
public Builder mutate() {
|
||||
Builder builder = ChatClient.builder(chatModel)
|
||||
.defaultSystem(s -> s.text(this.systemText).params(this.systemParams))
|
||||
.defaultUser(u -> u.text(this.userText)
|
||||
.params(this.userParams)
|
||||
.media(this.media.toArray(new Media[this.media.size()])))
|
||||
.defaultOptions(this.chatOptions)
|
||||
.defaultFunctions(StringUtils.toStringArray(this.functionNames));
|
||||
|
||||
// workaround to set the missing fields.
|
||||
builder.defaultRequest.messages.addAll(this.messages);
|
||||
builder.defaultRequest.functionCallbacks.addAll(this.functionCallbacks);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/* copy constructor */
|
||||
ChatClientRequest(ChatClientRequest ccr) {
|
||||
this(ccr.chatModel, ccr.userText, ccr.userParams, ccr.systemText, ccr.systemParams, ccr.functionCallbacks,
|
||||
@@ -411,7 +438,11 @@ public interface ChatClient {
|
||||
return doSingleWithBeanOutputConverter(new BeanOutputConverter<T>(type));
|
||||
}
|
||||
|
||||
private <T> T doSingleWithBeanOutputConverter(BeanOutputConverter<T> boc) {
|
||||
public <T> T entity(StructuredOutputConverter<T> structuredOutputConverter) {
|
||||
return doSingleWithBeanOutputConverter(structuredOutputConverter);
|
||||
}
|
||||
|
||||
private <T> T doSingleWithBeanOutputConverter(StructuredOutputConverter<T> boc) {
|
||||
var processedUserText = this.request.userText + System.lineSeparator() + System.lineSeparator()
|
||||
+ "{format}";
|
||||
var chatResponse = doGetChatResponse(processedUserText, boc.getFormat());
|
||||
@@ -435,13 +466,15 @@ public interface ChatClient {
|
||||
userParams.put("format", formatParam);
|
||||
}
|
||||
|
||||
var messages = new ArrayList<Message>();
|
||||
var messages = new ArrayList<Message>(this.request.messages);
|
||||
var textsAreValid = (StringUtils.hasText(processedUserText)
|
||||
|| StringUtils.hasText(this.request.systemText));
|
||||
var messagesAreValid = !this.request.messages.isEmpty();
|
||||
Assert.state(!(messagesAreValid && textsAreValid), "you must specify either " + Message.class.getName()
|
||||
+ " instances or user/system texts, but not both");
|
||||
if (textsAreValid) {
|
||||
if (StringUtils.hasText(this.request.systemText) || !this.request.systemParams.isEmpty()) {
|
||||
var systemMessage = new SystemMessage(
|
||||
new PromptTemplate(this.request.systemText, this.request.systemParams).render());
|
||||
messages.add(systemMessage);
|
||||
}
|
||||
UserMessage userMessage = null;
|
||||
if (!CollectionUtils.isEmpty(userParams)) {
|
||||
userMessage = new UserMessage(new PromptTemplate(processedUserText, userParams).render(),
|
||||
@@ -450,16 +483,8 @@ public interface ChatClient {
|
||||
else {
|
||||
userMessage = new UserMessage(processedUserText, this.request.media);
|
||||
}
|
||||
if (StringUtils.hasText(this.request.systemText) || !this.request.systemParams.isEmpty()) {
|
||||
var systemMessage = new SystemMessage(
|
||||
new PromptTemplate(this.request.systemText, this.request.systemParams).render());
|
||||
messages.add(systemMessage);
|
||||
}
|
||||
messages.add(userMessage);
|
||||
}
|
||||
else {
|
||||
messages.addAll(this.request.messages);
|
||||
}
|
||||
if (this.request.chatOptions instanceof FunctionCallingOptions functionCallingOptions) {
|
||||
// if (this.request.chatOptions instanceof
|
||||
// FunctionCallingOptionsBuilder.PortableFunctionCallingOptions
|
||||
|
||||
@@ -35,6 +35,15 @@ class DefaultChatClient implements ChatClient {
|
||||
return new ChatClientPromptRequest(this.chatModel, prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a {@code ChatClient.Builder} to create a new {@code ChatClient} whose
|
||||
* settings are replicated from this {@code ChatClientRequest}.
|
||||
*/
|
||||
@Override
|
||||
public Builder mutate() {
|
||||
return this.defaultChatClientRequest.mutate();
|
||||
}
|
||||
|
||||
/**
|
||||
* use the new fluid DSL starting in {@link #prompt()}
|
||||
* @param prompt the {@link Prompt prompt} object
|
||||
|
||||
@@ -19,6 +19,7 @@ package org.springframework.ai.chat.client;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -38,6 +39,9 @@ import org.springframework.ai.chat.messages.MessageType;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.model.function.FunctionCallingOptions;
|
||||
import org.springframework.ai.model.function.FunctionCallingOptionsBuilder;
|
||||
import org.springframework.ai.model.function.FunctionCallingOptionsBuilder.PortableFunctionCallingOptions;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.util.MimeTypeUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -198,6 +202,229 @@ public class ChatClientTest {
|
||||
assertThat(systemMessage.getMessageType()).isEqualTo(MessageType.SYSTEM);
|
||||
}
|
||||
|
||||
static Function<String, String> mockFunction = new Function<String, String>() {
|
||||
@Override
|
||||
public String apply(String s) {
|
||||
return s;
|
||||
}
|
||||
};
|
||||
|
||||
@Test
|
||||
public void mutateDefaults() {
|
||||
|
||||
PortableFunctionCallingOptions options = new FunctionCallingOptionsBuilder().build();
|
||||
when(chatModel.getDefaultOptions()).thenReturn(options);
|
||||
|
||||
when(chatModel.call(promptCaptor.capture())).thenReturn(new ChatResponse(List.of(new Generation("response"))));
|
||||
|
||||
when(chatModel.stream(promptCaptor.capture()))
|
||||
.thenReturn(Flux.generate(() -> new ChatResponse(List.of(new Generation("response"))), (state, sink) -> {
|
||||
sink.next(state);
|
||||
sink.complete();
|
||||
return state;
|
||||
}));
|
||||
|
||||
// @formatter:off
|
||||
var chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem(s -> s.text("Default system text {param1}, {param2}")
|
||||
.param("param1", "value1")
|
||||
.param("param2", "value2"))
|
||||
.defaultFunctions("fun1", "fun2")
|
||||
.defaultFunction("fun3", "fun3description", mockFunction)
|
||||
.defaultUser(u -> u.text("Default user text {uparam1}, {uparam2}")
|
||||
.param("uparam1", "value1")
|
||||
.param("uparam2", "value2")
|
||||
.media(MimeTypeUtils.IMAGE_JPEG,
|
||||
new DefaultResourceLoader().getResource("classpath:/bikes.json")))
|
||||
.build();
|
||||
// @formatter:on
|
||||
|
||||
var content = chatClient.prompt().call().content();
|
||||
|
||||
assertThat(content).isEqualTo("response");
|
||||
|
||||
Prompt prompt = promptCaptor.getValue();
|
||||
|
||||
Message systemMessage = prompt.getInstructions().get(0);
|
||||
assertThat(systemMessage.getMessageType()).isEqualTo(MessageType.SYSTEM);
|
||||
assertThat(systemMessage.getContent()).isEqualTo("Default system text value1, value2");
|
||||
|
||||
Message userMessage = prompt.getInstructions().get(1);
|
||||
assertThat(userMessage.getMessageType()).isEqualTo(MessageType.USER);
|
||||
assertThat(userMessage.getContent()).isEqualTo("Default user text value1, value2");
|
||||
assertThat(userMessage.getMedia()).hasSize(1);
|
||||
assertThat(userMessage.getMedia().iterator().next().getMimeType()).isEqualTo(MimeTypeUtils.IMAGE_JPEG);
|
||||
|
||||
var fco = (FunctionCallingOptions) prompt.getOptions();
|
||||
|
||||
assertThat(fco.getFunctions()).containsExactly("fun1", "fun2");
|
||||
assertThat(fco.getFunctionCallbacks().iterator().next().getName()).isEqualTo("fun3");
|
||||
|
||||
// Streaming
|
||||
content = join(chatClient.prompt().stream().content());
|
||||
|
||||
assertThat(content).isEqualTo("response");
|
||||
|
||||
prompt = promptCaptor.getValue();
|
||||
|
||||
systemMessage = prompt.getInstructions().get(0);
|
||||
assertThat(systemMessage.getMessageType()).isEqualTo(MessageType.SYSTEM);
|
||||
assertThat(systemMessage.getContent()).isEqualTo("Default system text value1, value2");
|
||||
|
||||
userMessage = prompt.getInstructions().get(1);
|
||||
assertThat(userMessage.getMessageType()).isEqualTo(MessageType.USER);
|
||||
assertThat(userMessage.getContent()).isEqualTo("Default user text value1, value2");
|
||||
assertThat(userMessage.getMedia()).hasSize(1);
|
||||
assertThat(userMessage.getMedia().iterator().next().getMimeType()).isEqualTo(MimeTypeUtils.IMAGE_JPEG);
|
||||
|
||||
fco = (FunctionCallingOptions) prompt.getOptions();
|
||||
|
||||
assertThat(fco.getFunctions()).containsExactly("fun1", "fun2");
|
||||
assertThat(fco.getFunctionCallbacks().iterator().next().getName()).isEqualTo("fun3");
|
||||
|
||||
// mutate builder
|
||||
// @formatter:off
|
||||
chatClient = chatClient.mutate()
|
||||
.defaultSystem("Mutated default system text {param1}, {param2}")
|
||||
.defaultFunctions("fun4")
|
||||
.defaultUser("Mutated default user text {uparam1}, {uparam2}")
|
||||
.build();
|
||||
// @formatter:on
|
||||
|
||||
content = chatClient.prompt().call().content();
|
||||
|
||||
assertThat(content).isEqualTo("response");
|
||||
|
||||
prompt = promptCaptor.getValue();
|
||||
|
||||
systemMessage = prompt.getInstructions().get(0);
|
||||
assertThat(systemMessage.getMessageType()).isEqualTo(MessageType.SYSTEM);
|
||||
assertThat(systemMessage.getContent()).isEqualTo("Mutated default system text value1, value2");
|
||||
|
||||
userMessage = prompt.getInstructions().get(1);
|
||||
assertThat(userMessage.getMessageType()).isEqualTo(MessageType.USER);
|
||||
assertThat(userMessage.getContent()).isEqualTo("Mutated default user text value1, value2");
|
||||
assertThat(userMessage.getMedia()).hasSize(1);
|
||||
assertThat(userMessage.getMedia().iterator().next().getMimeType()).isEqualTo(MimeTypeUtils.IMAGE_JPEG);
|
||||
|
||||
fco = (FunctionCallingOptions) prompt.getOptions();
|
||||
|
||||
assertThat(fco.getFunctions()).containsExactly("fun1", "fun2", "fun4");
|
||||
assertThat(fco.getFunctionCallbacks().iterator().next().getName()).isEqualTo("fun3");
|
||||
|
||||
// Streaming
|
||||
content = join(chatClient.prompt().stream().content());
|
||||
|
||||
assertThat(content).isEqualTo("response");
|
||||
|
||||
prompt = promptCaptor.getValue();
|
||||
|
||||
systemMessage = prompt.getInstructions().get(0);
|
||||
assertThat(systemMessage.getMessageType()).isEqualTo(MessageType.SYSTEM);
|
||||
assertThat(systemMessage.getContent()).isEqualTo("Mutated default system text value1, value2");
|
||||
|
||||
userMessage = prompt.getInstructions().get(1);
|
||||
assertThat(userMessage.getMessageType()).isEqualTo(MessageType.USER);
|
||||
assertThat(userMessage.getContent()).isEqualTo("Mutated default user text value1, value2");
|
||||
assertThat(userMessage.getMedia()).hasSize(1);
|
||||
assertThat(userMessage.getMedia().iterator().next().getMimeType()).isEqualTo(MimeTypeUtils.IMAGE_JPEG);
|
||||
|
||||
fco = (FunctionCallingOptions) prompt.getOptions();
|
||||
|
||||
assertThat(fco.getFunctions()).containsExactly("fun1", "fun2", "fun4");
|
||||
assertThat(fco.getFunctionCallbacks().iterator().next().getName()).isEqualTo("fun3");
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mutatePrompt() {
|
||||
|
||||
PortableFunctionCallingOptions options = new FunctionCallingOptionsBuilder().build();
|
||||
when(chatModel.getDefaultOptions()).thenReturn(options);
|
||||
|
||||
when(chatModel.call(promptCaptor.capture())).thenReturn(new ChatResponse(List.of(new Generation("response"))));
|
||||
|
||||
when(chatModel.stream(promptCaptor.capture()))
|
||||
.thenReturn(Flux.generate(() -> new ChatResponse(List.of(new Generation("response"))), (state, sink) -> {
|
||||
sink.next(state);
|
||||
sink.complete();
|
||||
return state;
|
||||
}));
|
||||
// @formatter:off
|
||||
var chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem(s -> s.text("Default system text {param1}, {param2}")
|
||||
.param("param1", "value1")
|
||||
.param("param2", "value2"))
|
||||
.defaultFunctions("fun1", "fun2")
|
||||
.defaultFunction("fun3", "fun3description", mockFunction)
|
||||
.defaultUser(u -> u.text("Default user text {uparam1}, {uparam2}")
|
||||
.param("uparam1", "value1")
|
||||
.param("uparam2", "value2")
|
||||
.media(MimeTypeUtils.IMAGE_JPEG,
|
||||
new DefaultResourceLoader().getResource("classpath:/bikes.json")))
|
||||
.build();
|
||||
|
||||
var content = chatClient
|
||||
.prompt()
|
||||
.system("New default system text {param1}, {param2}")
|
||||
.user(u -> u.param("uparam1", "userValue1")
|
||||
.param("uparam2", "userValue2"))
|
||||
.functions("fun5")
|
||||
.mutate().build() // mutate and build new prompt
|
||||
.prompt().call().content();
|
||||
// @formatter:on
|
||||
|
||||
assertThat(content).isEqualTo("response");
|
||||
|
||||
Prompt prompt = promptCaptor.getValue();
|
||||
|
||||
Message systemMessage = prompt.getInstructions().get(0);
|
||||
assertThat(systemMessage.getMessageType()).isEqualTo(MessageType.SYSTEM);
|
||||
assertThat(systemMessage.getContent()).isEqualTo("New default system text value1, value2");
|
||||
|
||||
Message userMessage = prompt.getInstructions().get(1);
|
||||
assertThat(userMessage.getMessageType()).isEqualTo(MessageType.USER);
|
||||
assertThat(userMessage.getContent()).isEqualTo("Default user text userValue1, userValue2");
|
||||
assertThat(userMessage.getMedia()).hasSize(1);
|
||||
assertThat(userMessage.getMedia().iterator().next().getMimeType()).isEqualTo(MimeTypeUtils.IMAGE_JPEG);
|
||||
|
||||
var fco = (FunctionCallingOptions) prompt.getOptions();
|
||||
|
||||
assertThat(fco.getFunctions()).containsExactly("fun1", "fun2", "fun5");
|
||||
assertThat(fco.getFunctionCallbacks().iterator().next().getName()).isEqualTo("fun3");
|
||||
|
||||
// Streaming
|
||||
// @formatter:off
|
||||
content = join(chatClient
|
||||
.prompt()
|
||||
.system("New default system text {param1}, {param2}")
|
||||
.user(u -> u.param("uparam1", "userValue1")
|
||||
.param("uparam2", "userValue2"))
|
||||
.functions("fun5")
|
||||
.mutate().build() // mutate and build new prompt
|
||||
.prompt().stream().content());
|
||||
// @formatter:on
|
||||
|
||||
assertThat(content).isEqualTo("response");
|
||||
|
||||
prompt = promptCaptor.getValue();
|
||||
|
||||
systemMessage = prompt.getInstructions().get(0);
|
||||
assertThat(systemMessage.getMessageType()).isEqualTo(MessageType.SYSTEM);
|
||||
assertThat(systemMessage.getContent()).isEqualTo("New default system text value1, value2");
|
||||
|
||||
userMessage = prompt.getInstructions().get(1);
|
||||
assertThat(userMessage.getMessageType()).isEqualTo(MessageType.USER);
|
||||
assertThat(userMessage.getContent()).isEqualTo("Default user text userValue1, userValue2");
|
||||
assertThat(userMessage.getMedia()).hasSize(1);
|
||||
assertThat(userMessage.getMedia().iterator().next().getMimeType()).isEqualTo(MimeTypeUtils.IMAGE_JPEG);
|
||||
|
||||
fco = (FunctionCallingOptions) prompt.getOptions();
|
||||
|
||||
assertThat(fco.getFunctions()).containsExactly("fun1", "fun2", "fun5");
|
||||
assertThat(fco.getFunctionCallbacks().iterator().next().getName()).isEqualTo("fun3");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultUserText() {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user