diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java index 9a0e9c98e..758d27f75 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java @@ -62,13 +62,12 @@ class OpenAiChatClientIT extends AbstractIT { @Test void roleTest() { - ChatResponse response = ChatClient.builder(modelCaller) - .build() - .call() - .system(s -> s.text(systemTextResource).param("name", "Bob").param("voice", "pirate")) - .user(u -> u.text("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did")) - .collect() - .chatResponse(); + ChatResponse response = ChatClient.builder(modelCaller).build().prompt() + .system(s -> s.text(systemTextResource) + .param("name", "Bob") + .param("voice", "pirate")) + .user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did") + .call().chatResponse(); System.out.println(response); // UserMessage userMessage = new UserMessage( @@ -89,12 +88,10 @@ class OpenAiChatClientIT extends AbstractIT { void listOutputConverter() { // TODO: there is a problem here. - Collection list = ChatClient.builder(modelCaller) - .build() - .call() - .user(u -> u.text("List five {subject}").param("subject", "ice cream flavors")) - .collect() - .list(String.class); + Collection list = ChatClient.builder(modelCaller).build().prompt() + .user(u -> u.text("List five {subject}") + .param("subject", "ice cream flavors")) + .call().list(String.class); // DefaultConversionService conversionService = new DefaultConversionService(); // ListOutputConverter outputConverter = new @@ -119,14 +116,11 @@ class OpenAiChatClientIT extends AbstractIT { @Test void mapOutputConverter() { - Map result = ChatClient.builder(modelCaller) - .build() - .call() - .user(u -> u.text("Provide me a List of {subject}") - .param("subject", "an array of numbers from 1 to 9 under they key name 'numbers'")) - .collect() - .single(new ParameterizedTypeReference>() { - }); + Map result = ChatClient.builder(modelCaller).build().prompt() + .user(u -> u.text("Provide me a List of {subject}") + .param("subject", "an array of numbers from 1 to 9 under they key name 'numbers'")) + .call().single(new ParameterizedTypeReference>() { + }); // MapOutputConverter outputConverter = new MapOutputConverter(); @@ -149,12 +143,10 @@ class OpenAiChatClientIT extends AbstractIT { @Test void beanOutputConverter() { - ActorsFilms actorsFilms = ChatClient.builder(modelCaller) - .build() - .call() - .user(u -> u.text("Generate the filmography for a random actor.")) - .collect() - .single(ActorsFilms.class); + ActorsFilms actorsFilms = ChatClient.builder(modelCaller).build().prompt() + .user("Generate the filmography for a random actor.") + .call() + .single(ActorsFilms.class); // BeanOutputConverter outputConverter = new // BeanOutputConverter<>(ActorsFilms.class); @@ -181,12 +173,10 @@ class OpenAiChatClientIT extends AbstractIT { @Test void beanOutputConverterRecords() { - ActorsFilmsRecord actorsFilms = ChatClient.builder(modelCaller) - .build() - .call() - .user(u -> u.text("Generate the filmography of 5 movies for Tom Hanks.")) - .collect() - .single(ActorsFilmsRecord.class); + ActorsFilmsRecord actorsFilms = ChatClient.builder(modelCaller).build().prompt() + .user("Generate the filmography of 5 movies for Tom Hanks.") + .call() + .single(ActorsFilmsRecord.class); // BeanOutputConverter outputConverter = new // BeanOutputConverter<>(ActorsFilmsRecord.class); @@ -213,22 +203,20 @@ class OpenAiChatClientIT extends AbstractIT { BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class); - Flux chatResponse = ChatClient.builder(modelCaller) - .build() - .call() - .user(u -> u - .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + "{format}") - .param("format", outputConverter.getFormat())) - .stream() - .chatResponse(); + Flux chatResponse = ChatClient.builder(modelCaller) + .build() + .prompt() + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "{format}") + .param("format", outputConverter.getFormat())) + .stream() + .content(); String generationTextFromStream = chatResponse.collectList() - .block() - .stream() - .map(ChatResponse::getResult) - .map(Generation::getOutput) - .map(AssistantMessage::getContent) - .collect(Collectors.joining()); + .block() + .stream() + .collect(Collectors.joining()); // String generationTextFromStream = chatResponse.collectList() // .block() @@ -266,13 +254,11 @@ class OpenAiChatClientIT extends AbstractIT { @Test void functionCallTest() { - ChatResponse response = ChatClient.builder(modelCaller) - .build() - .call() - .user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?")) - .function("getCurrentWeather", "Get the weather in location", new MockWeatherService()) - .collect() - .chatResponse(); + String response = ChatClient.builder(modelCaller).build().prompt() + .user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?")) + .function("getCurrentWeather", "Get the weather in location", new MockWeatherService()) + .call() + .content(); // UserMessage userMessage = new UserMessage("What's the weather like in San // Francisco, Tokyo, and Paris?"); @@ -293,21 +279,19 @@ class OpenAiChatClientIT extends AbstractIT { logger.info("Response: {}", response); - assertThat(response.getResult().getOutput().getContent()).containsAnyOf("30.0", "30"); - assertThat(response.getResult().getOutput().getContent()).containsAnyOf("10.0", "10"); - assertThat(response.getResult().getOutput().getContent()).containsAnyOf("15.0", "15"); + assertThat(response).containsAnyOf("30.0", "30"); + assertThat(response).containsAnyOf("10.0", "10"); + assertThat(response).containsAnyOf("15.0", "15"); } @Test void streamFunctionCallTest() { - Flux response = ChatClient.builder(modelCaller) - .build() - .call() - .user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?")) - .function("getCurrentWeather", "Get the weather in location", new MockWeatherService()) - .stream() - .chatResponse(); + Flux response = ChatClient.builder(modelCaller).build().prompt() + .user("What's the weather like in San Francisco, Tokyo, and Paris?") + .function("getCurrentWeather", "Get the weather in location", new MockWeatherService()) + .stream() + .content(); // UserMessage userMessage = new UserMessage("What's the weather like in San // Francisco, Tokyo, and Paris?"); @@ -328,13 +312,9 @@ class OpenAiChatClientIT extends AbstractIT { // promptOptions)); String content = response.collectList() - .block() - .stream() - .map(ChatResponse::getResults) - .flatMap(List::stream) - .map(Generation::getOutput) - .map(AssistantMessage::getContent) - .collect(Collectors.joining()); + .block() + .stream() + .collect(Collectors.joining()); logger.info("Response: {}", content); assertThat(content).containsAnyOf("30.0", "30"); @@ -346,16 +326,14 @@ class OpenAiChatClientIT extends AbstractIT { @ValueSource(strings = { "gpt-4-vision-preview", "gpt-4o" }) void multiModalityEmbeddedImage(String modelName) throws IOException { - ChatResponse response = ChatClient.builder(modelCaller) - .build() - .call() - // TODO consider adding model(...) method to ChatClient as a shortcut to - // OpenAiChatOptions.builder().withModel(modelName).build() - .options(OpenAiChatOptions.builder().withModel(modelName).build()) - .user(u -> u.text("Explain what do you see on this picture?") - .media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.png"))) - .collect() - .chatResponse(); + String response = ChatClient.builder(modelCaller).build().prompt() + // TODO consider adding model(...) method to ChatClient as a shortcut to + // OpenAiChatOptions.builder().withModel(modelName).build() + .options(OpenAiChatOptions.builder().withModel(modelName).build()) + .user(u -> u.text("Explain what do you see on this picture?") + .media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.png"))) + .call() + .content(); // var imageData = new ClassPathResource("/test.png"); @@ -366,9 +344,9 @@ class OpenAiChatClientIT extends AbstractIT { // .call(new Prompt(List.of(userMessage), // OpenAiChatOptions.builder().withModel(modelName).build())); - logger.info(response.getResult().getOutput().getContent()); - assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple"); - assertThat(response.getResult().getOutput().getContent()).containsAnyOf("bowl", "basket"); + logger.info(response); + assertThat(response).contains("bananas", "apple"); + assertThat(response).containsAnyOf("bowl", "basket"); } @ParameterizedTest(name = "{0} : {displayName} ") @@ -378,15 +356,15 @@ class OpenAiChatClientIT extends AbstractIT { // TODO: add url method that wrapps the checked exception. URL url = new URL("https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png"); - ChatResponse response = ChatClient.builder(modelCaller) - .build() - .call() - // TODO consider adding model(...) method to ChatClient as a shortcut to - // OpenAiChatOptions.builder().withModel(modelName).build() - .options(OpenAiChatOptions.builder().withModel(modelName).build()) - .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, url)) - .collect() - .chatResponse(); + String response = ChatClient.builder(modelCaller) + .build() + .prompt() + // TODO consider adding model(...) method to ChatClient as a shortcut to + // OpenAiChatOptions.builder().withModel(modelName).build() + .options(OpenAiChatOptions.builder().withModel(modelName).build()) + .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, url)) + .call() + .content(); // var userMessage = new UserMessage("Explain what do you see on this picture?", // List @@ -398,9 +376,9 @@ class OpenAiChatClientIT extends AbstractIT { // .call(new Prompt(List.of(userMessage), // OpenAiChatOptions.builder().withModel(modelName).build())); - logger.info(response.getResult().getOutput().getContent()); - assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple"); - assertThat(response.getResult().getOutput().getContent()).containsAnyOf("bowl", "basket"); + logger.info(response); + assertThat(response).contains("bananas", "apple"); + assertThat(response).containsAnyOf("bowl", "basket"); } @Test @@ -409,19 +387,16 @@ class OpenAiChatClientIT extends AbstractIT { // TODO: add url method that wrapps the checked exception. URL url = new URL("https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png"); - Flux response = ChatClient.builder(modelCaller) - .build() - .call() - // TODO consider adding model(...) method to ChatClient as a shortcut to - // OpenAiChatOptions.builder().withModel(modelName).build() - .options(OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_VISION_PREVIEW.getValue()).build()) - .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, url)) - .stream() - .chatResponse(); + Flux response = ChatClient.builder(modelCaller).build().prompt() + .options(OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_VISION_PREVIEW.getValue()) + .build()) + .user(u -> u.text("Explain what do you see on this picture?") + .media(MimeTypeUtils.IMAGE_PNG, url)) + .stream() + .content(); // var userMessage = new UserMessage("Explain what do you see on this picture?", - // List - // .of(new Media(MimeTypeUtils.IMAGE_PNG, + // List.of(new Media(MimeTypeUtils.IMAGE_PNG, // new // URL("https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png")))); @@ -429,14 +404,8 @@ class OpenAiChatClientIT extends AbstractIT { // Prompt(List.of(userMessage), // OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_VISION_PREVIEW.getValue()).build())); - String content = response.collectList() - .block() - .stream() - .map(ChatResponse::getResults) - .flatMap(List::stream) - .map(Generation::getOutput) - .map(AssistantMessage::getContent) - .collect(Collectors.joining()); + String content = response.collectList().block().stream().collect(Collectors.joining()); + logger.info("Response: {}", content); assertThat(content).contains("bananas", "apple"); assertThat(content).containsAnyOf("bowl", "basket"); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatClient.java index 82fa65d7c..cd71d8462 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatClient.java @@ -64,7 +64,7 @@ public interface ChatClient { ChatResponse call(Prompt prompt); - ChatClientRequest call(); + ChatClientRequest prompt(); interface PromptSpec { @@ -268,18 +268,18 @@ public interface ChatClient { return this; } - public static class CollectResponseSpec { + public static class CallResponseSpec { private final ChatClientRequest request; private final ChatCaller modelCaller; - public CollectResponseSpec(ChatCaller modelCaller, ChatClientRequest request) { + public CallResponseSpec(ChatCaller modelCaller, ChatClientRequest request) { this.modelCaller = modelCaller; this.request = request; } - public T single(ParameterizedTypeReference t) { + public T single(ParameterizedTypeReference type) { return doSingleWithBeanOutputConverter(new BeanOutputConverter(new ParameterizedTypeReference<>() { })); } @@ -292,9 +292,9 @@ public interface ChatClient { return boc.convert(stringResponse); } - public T single(Class clzz) { - Assert.notNull(clzz, "the class must be non-null"); - var boc = new BeanOutputConverter(clzz); + public T single(Class type) { + Assert.notNull(type, "the class must be non-null"); + var boc = new BeanOutputConverter(type); return doSingleWithBeanOutputConverter(boc); } @@ -462,9 +462,6 @@ public interface ChatClient { public Flux content() { return doGetFluxChatResponse(this.request.userText) - // .map(ChatResponse::getResult) - // .map(Generation::getOutput) - // .map(AssistantMessage::getContent); .map(r -> { if (r.getResult() == null || r.getResult().getOutput() == null || r.getResult().getOutput().getContent() == null) { @@ -487,8 +484,8 @@ public interface ChatClient { } - public CollectResponseSpec collect() { - return new CollectResponseSpec(this.caller, this); + public CallResponseSpec call() { + return new CallResponseSpec(this.caller, this); } public StreamResponseSpec stream() { diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/DefaultChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/DefaultChatClient.java index 5abc0ed0f..7a5e92c36 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/DefaultChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/DefaultChatClient.java @@ -20,12 +20,12 @@ class DefaultChatClient implements ChatClient { } @Override - public ChatClientRequest call() { + public ChatClientRequest prompt() { return new ChatClientRequest(this.defaultChatClientRequest); } /** - * use the new fluid DSL starting in {@link #call()} + * use the new fluid DSL starting in {@link #prompt()} * @param prompt the {@link Prompt prompt} object * @return a {@link ChatResponse chat response} */ diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/Main.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/Main.java index ac2e143cc..08cd269c6 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/chat/Main.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/Main.java @@ -46,9 +46,9 @@ public class Main { .defaultFunctions("function1") .build(); - String response = client.call() + String response = client.prompt() .user(u -> u.text("User text {music}").param("music", "Rock").media(MimeTypeUtils.IMAGE_PNG, url)) - .collect() + .call() .single(String.class); } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPrompt2IT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPrompt2IT.java new file mode 100644 index 000000000..8d7606f81 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackInPrompt2IT.java @@ -0,0 +1,115 @@ +/* + * 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.openai.tool; + +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.openai.OpenAiModelCaller; +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; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +public class FunctionCallbackInPrompt2IT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.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)); + + @Test + void functionCallTest() { + contextRunner.withPropertyValues("spring.ai.openai.chat.options.model=gpt-4-turbo-preview").run(context -> { + + OpenAiModelCaller caller = context.getBean(OpenAiModelCaller.class); + + ChatClient chatClient = ChatClient.builder(caller).build(); + + chatClient.prompt() + .user("Tell me a joke?") + .call().content(); + + String content = ChatClient.builder(caller).build().prompt() + .user("What's the weather like in San Francisco, Tokyo, and Paris?") + .function("CurrentWeatherService", "Get the weather in location", new MockWeatherService()) + .call().content(); + + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + + @Test + void functionCallTest2() { + contextRunner.withPropertyValues("spring.ai.openai.chat.options.model=gpt-4-turbo-preview").run(context -> { + + OpenAiModelCaller caller = context.getBean(OpenAiModelCaller.class); + + String content = ChatClient.builder(caller).build().prompt() + .user("What's the weather like in Amsterdam?") + .function("CurrentWeatherService", "Get the weather in location", + new Function() { + @Override + public String apply(MockWeatherService.Request request) { + return "18 degrees Celsius"; + } + }) + .call().content(); + + logger.info("Response: {}", content); + + assertThat(content).contains("18"); + }); + } + + @Test + void streamingFunctionCallTest() { + + contextRunner.withPropertyValues("spring.ai.openai.chat.options.model=gpt-4-turbo-preview").run(context -> { + + OpenAiModelCaller caller = context.getBean(OpenAiModelCaller.class); + + String content = ChatClient.builder(caller).build().prompt() + .user("What's the weather like in San Francisco, Tokyo, and Paris?") + .function("CurrentWeatherService", "Get the weather in location", new MockWeatherService()) + .stream().content() + .collectList().block().stream().collect(Collectors.joining()); + + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWrapper2IT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWrapper2IT.java index e78c99e11..47fc147c1 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWrapper2IT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/FunctionCallbackWrapper2IT.java @@ -58,10 +58,9 @@ public class FunctionCallbackWrapper2IT { .defaultUser(u -> u.text("What's the weather like in {cities}?")) .build(); - String content = chatClient.call() + String content = chatClient.prompt() .user(u -> u.param("cities", "San Francisco, Tokyo, Paris")) - .collect() - .content(); + .call().content(); logger.info("Response: {}", content); @@ -77,17 +76,11 @@ public class FunctionCallbackWrapper2IT { OpenAiModelCaller caller = context.getBean(OpenAiModelCaller.class); - String content = ChatClient.builder(caller) - .build() - .call() + String content = ChatClient.builder(caller).build().prompt() .functions("WeatherInfo") .user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?")) - .stream() - .content() - .collectList() - .block() - .stream() - .collect(Collectors.joining()); + .stream().content() + .collectList().block().stream().collect(Collectors.joining()); logger.info("Response: {}", content);