From b2a4f0176110de6935ef8e8a7a858e638dc7d347 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Wed, 6 Sep 2023 17:05:50 -0400 Subject: [PATCH] Add Spring Annotation for registerding Function Calling tools. - Built on top of the existing ToolFunctionCallback utilites. - Organized as part of auto-configuration project under the /common/function package to be reusable for different model implementations. - Add OpenAI support for the SpringAiFunction annotation. - Update the OpenAI function calling documentation. - Add ITs Co-Authored-By: Christian Tzolov --- .../ai/chat/messages/FunctionMessage.java | 4 +- .../functions/openai-chat-functions.adoc | 37 +++++- .../common/function/SpringAiFunction.java | 42 +++++++ .../SpringAiFunctionAnnotationManager.java | 111 ++++++++++++++++++ .../openai/OpenAiAutoConfiguration.java | 19 ++- ...lCallWithSpringAIFunctionAnnotationIT.java | 80 +++++++++++++ 6 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunction.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunctionAnnotationManager.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithSpringAIFunctionAnnotationIT.java diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/FunctionMessage.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/FunctionMessage.java index 4b14b65a3..2f115517c 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/FunctionMessage.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/FunctionMessage.java @@ -21,11 +21,11 @@ import java.util.Map; public class FunctionMessage extends AbstractMessage { public FunctionMessage(String content) { - super(MessageType.FUNCTION, content); + super(MessageType.SYSTEM, content); } public FunctionMessage(String content, Map properties) { - super(MessageType.FUNCTION, content, properties); + super(MessageType.SYSTEM, content, properties); } @Override diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/functions/openai-chat-functions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/functions/openai-chat-functions.adoc index 25dbca1e7..669a9b246 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/functions/openai-chat-functions.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/functions/openai-chat-functions.adoc @@ -64,16 +64,16 @@ The Spring AI auto-generates the JSON Scheme for the `MockWeatherService.Request If you enable the link:../openai-chat.html#_auto_configuration[OpenAiChatClient Auto-Configuration], the easiest way to register a function is to created it as a bean in the Spring context: -[source,java,linenums] +[source,java] ---- @Configuration static class Config { @Bean public WeatherFunctionCallback weatherFunctionInfo() { return new WeatherFunctionCallback( - "CurrentWeather", // (1) name - "Get the weather in location", // (2) description - MockWeatherService.Request.class); // (3) signature + "CurrentWeather", // (1) function name + "Get the weather in location", // (2) function description + MockWeatherService.Request.class); // (3) function input signature } ... } @@ -106,6 +106,35 @@ Here is the current weather for the requested cities: The link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithBeanFunctionRegistrationIT.java[ToolCallWithBeanFunctionRegistrationIT.java] integration test provides a complete example of how to register a function with the `OpenAiChatClient` using the auto-configuration. +==== @SpringAiFunction + +You can use the `SpringAiFunction` annotation cam be used to register a `java.util.Function` as a `ToolFunctionCallback` bean: + +[source,java] +---- +@Configuration +static class Config { + + @SpringAiFunction( + name = "CurrentWeather", // (1) + description = "Get the weather in location", // (2) + classType = MockWeatherService.Request.class) // (3) + public Function weatherFunction() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + ... +} +---- + +The `@SpringAiFunction` annotation defines the function name (1), description (2), and input signature (3) and registers the function as a bean in the Spring context. + +NOTE: The `SpringAiFunction` annotation supported only if the auto-configuration is enabled. + +NOTE: The Function implementation is responsible to convert the response into a text as expected by the model. +By default, the `AbstractToolFunctionCallback` provides a default converter that returns the `toString()` of the response object. + === Register/Call Functions with Prompt Options In addition to the auto-configuration you can register callback functions, dynamically, with your Prompt requests: diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunction.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunction.java new file mode 100644 index 000000000..812d5de53 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunction.java @@ -0,0 +1,42 @@ +/* + * 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.common.function; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Bean; + +/** + * An annotation used to define functions for use in + * + * @author Christopher Smith + */ +@Bean +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface SpringAiFunction { + + String name(); + + String description(); + + Class classType(); + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunctionAnnotationManager.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunctionAnnotationManager.java new file mode 100644 index 000000000..c85e297d6 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunctionAnnotationManager.java @@ -0,0 +1,111 @@ +/* + * 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.common.function; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.ai.model.AbstractToolFunctionCallback; +import org.springframework.ai.model.ToolFunctionCallback; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ReflectionUtils; + +/** + * Manages the chat functions that are annotated with {@link SpringAiFunction}. + * + * @author Christopher Smith + * @author Christian Tzolov + */ +public class SpringAiFunctionAnnotationManager implements ApplicationContextAware { + + private GenericApplicationContext applicationContext; + + @Override + public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException { + this.applicationContext = (GenericApplicationContext) applicationContext; + } + + /** + * @return a list of all the {@link java.util.Function}s annotated with + * {@link SpringAiFunction}. + */ + public List getAnnotatedToolFunctionCallbacks() { + Map beans = this.applicationContext.getBeansWithAnnotation(SpringAiFunction.class); + + List toolFunctionCallbacks = new ArrayList<>(); + + if (!CollectionUtils.isEmpty(beans)) { + + beans.forEach((k, v) -> { + if (v instanceof Function function) { + SpringAiFunction functionAnnotation = applicationContext.findAnnotationOnBean(k, + SpringAiFunction.class); + + toolFunctionCallbacks.add(new SpringAiFunctionToolFunctionCallback(functionAnnotation.name(), + functionAnnotation.description(), functionAnnotation.classType(), function)); + } + else { + ReflectionUtils.handleReflectionException(new IllegalArgumentException( + "Bean annotated with @SpringAiFunction must be of type Function")); + } + }); + + } + + return toolFunctionCallbacks; + } + + /** + * Note that the underlying function is responsible for converting the output into + * format that can be consumed by the Model. The default implementation converts the + * output into String before sending it to the Model. Provide a custom Function responseConverter implementation to override this. + * + */ + public static class SpringAiFunctionToolFunctionCallback extends AbstractToolFunctionCallback { + + private Function function; + + protected SpringAiFunctionToolFunctionCallback(String name, String description, Class inputType, + Function function) { + super(name, description, inputType); + Assert.notNull(function, "Function must not be null"); + this.function = function; + } + + protected SpringAiFunctionToolFunctionCallback(String name, String description, Class inputType, + Function responseConverter, Function function) { + super(name, description, inputType, responseConverter); + Assert.notNull(function, "Function must not be null"); + this.function = function; + } + + @Override + public O apply(I input) { + return this.function.apply(input); + } + + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java index b1c13a323..07d2e02c9 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java @@ -19,6 +19,7 @@ package org.springframework.ai.autoconfigure.openai; import java.util.List; import org.springframework.ai.autoconfigure.NativeHints; +import org.springframework.ai.autoconfigure.common.function.SpringAiFunctionAnnotationManager; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.model.ToolFunctionCallback; import org.springframework.ai.openai.OpenAiChatClient; @@ -31,6 +32,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ImportRuntimeHints; import org.springframework.util.Assert; @@ -38,7 +40,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; -@AutoConfiguration(after = RestClientAutoConfiguration.class) +@AutoConfiguration(after = { RestClientAutoConfiguration.class }) @ConditionalOnClass(OpenAiApi.class) @EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiChatProperties.class, OpenAiEmbeddingProperties.class, OpenAiImageProperties.class }) @@ -56,7 +58,7 @@ public class OpenAiAutoConfiguration { @ConditionalOnMissingBean public OpenAiChatClient openAiChatClient(OpenAiConnectionProperties commonProperties, OpenAiChatProperties chatProperties, RestClient.Builder restClientBuilder, - List toolFunctionCallbacks) { + List toolFunctionCallbacks, SpringAiFunctionAnnotationManager functionManager) { String apiKey = StringUtils.hasText(chatProperties.getApiKey()) ? chatProperties.getApiKey() : commonProperties.getApiKey(); @@ -73,6 +75,11 @@ public class OpenAiAutoConfiguration { chatProperties.getOptions().getToolCallbacks().addAll(toolFunctionCallbacks); } + var annotatedFunctionsList = functionManager.getAnnotatedToolFunctionCallbacks(); + if (!CollectionUtils.isEmpty(annotatedFunctionsList)) { + chatProperties.getOptions().getToolCallbacks().addAll(annotatedFunctionsList); + } + return new OpenAiChatClient(openAiApi, chatProperties.getOptions()); } @@ -113,4 +120,12 @@ public class OpenAiAutoConfiguration { return new OpenAiImageClient(openAiImageApi).withDefaultOptions(imageProperties.getOptions()); } + @Bean + @ConditionalOnMissingBean + public SpringAiFunctionAnnotationManager springAiFunctionManager(ApplicationContext context) { + SpringAiFunctionAnnotationManager manager = new SpringAiFunctionAnnotationManager(); + manager.setApplicationContext(context); + return manager; + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithSpringAIFunctionAnnotationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithSpringAIFunctionAnnotationIT.java new file mode 100644 index 000000000..88f3164eb --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithSpringAIFunctionAnnotationIT.java @@ -0,0 +1,80 @@ +/* + * 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.openai.tool; + +import java.util.List; +import java.util.function.Function; + +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.common.function.SpringAiFunction; +import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatClient; +import org.springframework.ai.openai.OpenAiChatOptions; +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.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +class ToolCallWithSpringAIFunctionAnnotationIT { + + private final Logger logger = LoggerFactory.getLogger(ToolCallWithBeanFunctionRegistrationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")) + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, OpenAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + contextRunner.withPropertyValues("spring.ai.openai.chat.options.model=gpt-4-1106-preview").run(context -> { + + OpenAiChatClient chatClient = context.getBean(OpenAiChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().withEnabledFunction("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + + }); + } + + @Configuration + static class Config { + @SpringAiFunction(name = "WeatherInfo", description = "Get the weather in location", + classType = MockWeatherService.Request.class) + public Function weatherFunction() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} \ No newline at end of file