diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java index ef27b2166..c7d608ad1 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java @@ -37,7 +37,7 @@ import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.metadata.RateLimit; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.ModelOptionsUtils; -import org.springframework.ai.model.ToolFunctionCallback; +import org.springframework.ai.model.function.ToolFunctionCallback; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage; diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java index 7b766a4aa..e825d8884 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java @@ -17,6 +17,7 @@ package org.springframework.ai.openai; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -28,9 +29,10 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import org.springframework.ai.chat.ChatOptions; -import org.springframework.ai.model.ToolFunctionCallback; +import org.springframework.ai.model.function.ToolFunctionCallback; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ResponseFormat; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ToolChoice; +import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.util.Assert; import org.springframework.ai.openai.api.OpenAiApi.FunctionTool; @@ -89,6 +91,7 @@ public class OpenAiChatOptions implements ChatOptions { /** * Up to 4 sequences where the API will stop generating further tokens. */ + @NestedConfigurationProperty private @JsonProperty("stop") List stop; /** * What sampling temperature to use, between 0 and 1. Higher values like 0.8 will make the output @@ -106,6 +109,7 @@ public class OpenAiChatOptions implements ChatOptions { * A list of tools the model may call. Currently, only functions are supported as a tool. Use this to * provide a list of functions the model may generate JSON inputs for. */ + @NestedConfigurationProperty private @JsonProperty("tools") List tools; /** * Controls which (if any) function is called by the model. none means the model will not call a @@ -114,6 +118,7 @@ public class OpenAiChatOptions implements ChatOptions { * the model to call that function. none is the default when no functions are present. auto is the default if * functions are present. */ + @NestedConfigurationProperty private @JsonProperty("tool_choice") ToolChoice toolChoice; /** * A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. @@ -126,6 +131,7 @@ public class OpenAiChatOptions implements ChatOptions { * For Default Options the toolCallbacks are registered but disabled by default. Use the enableFunctions to set the functions * from the registry to be used by the ChatClient chat completion requests. */ + @NestedConfigurationProperty @JsonIgnore private List toolCallbacks = new ArrayList<>(); @@ -138,8 +144,20 @@ public class OpenAiChatOptions implements ChatOptions { * Note that function enabled with the default options are enabled for all chat completion requests. This could impact the token count and the billing. * If the enabledFunctions is set in a prompt options, then the enabled functions are only active for the duration of this prompt execution. */ + @NestedConfigurationProperty @JsonIgnore private Set enabledFunctions = new HashSet<>(); + + /** + * Map of bean names and their descriptions to register as function callbacks. + * For example `spring.ai.openai.chat.options.beanFunctions.spring.ai.openai.chat.options.beanFunctions.weatherInfo` * or with + * description `spring.ai.openai.chat.options.beanFunctions.spring.ai.openai.chat.options.beanFunctions.weatherInfo=Get the weather in location`. + * The description is optional. + * Each bean name should be specified in a separate property. + */ + @NestedConfigurationProperty + @JsonIgnore + private Map beanFunctions = new HashMap<>(); // @formatter:on public static Builder builder() { @@ -245,6 +263,16 @@ public class OpenAiChatOptions implements ChatOptions { return this; } + public Builder withBeanFunctions(Map beanFunctions) { + this.options.beanFunctions = beanFunctions; + return this; + } + + public Builder withBeanFunction(String beanName, String description) { + this.options.beanFunctions.put(beanName, description); + return this; + } + public OpenAiChatOptions build() { return this.options; } @@ -383,6 +411,14 @@ public class OpenAiChatOptions implements ChatOptions { this.enabledFunctions = functionNames; } + public Map getBeanFunctions() { + return beanFunctions; + } + + public void setBeanFunctions(Map beanFunctions) { + this.beanFunctions = beanFunctions; + } + @Override public int hashCode() { final int prime = 31; diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/ChatCompletionRequestTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/ChatCompletionRequestTests.java index 185602202..0b55a763f 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/ChatCompletionRequestTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/ChatCompletionRequestTests.java @@ -21,7 +21,7 @@ import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.model.AbstractToolFunctionCallback; +import org.springframework.ai.model.function.AbstractToolFunctionCallback; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.chat.api.tool.MockWeatherService; import org.springframework.ai.openai.chat.api.tool.MockWeatherService.Request; 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 8d558adb9..1562dedba 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 @@ -19,7 +19,7 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.chat.prompt.SystemPromptTemplate; -import org.springframework.ai.model.AbstractToolFunctionCallback; +import org.springframework.ai.model.function.AbstractToolFunctionCallback; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.OpenAiTestConfiguration; import org.springframework.ai.openai.chat.api.tool.MockWeatherService; diff --git a/spring-ai-core/pom.xml b/spring-ai-core/pom.xml index 6f7090d86..ad1dc959f 100644 --- a/spring-ai-core/pom.xml +++ b/spring-ai-core/pom.xml @@ -25,6 +25,13 @@ + + + org.springframework.cloud + spring-cloud-function-context + 4.1.0 + + org.antlr diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java index 8cdadae8d..3e27fc4ca 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java @@ -16,10 +16,7 @@ package org.springframework.ai.chat; -import java.util.List; - import org.springframework.ai.model.ModelOptions; -import org.springframework.ai.model.ToolFunctionCallback; /** * The ChatOptions represent the common options, portable across different chat models. diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/AbstractToolFunctionCallback.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/AbstractToolFunctionCallback.java similarity index 90% rename from spring-ai-core/src/main/java/org/springframework/ai/model/AbstractToolFunctionCallback.java rename to spring-ai-core/src/main/java/org/springframework/ai/model/function/AbstractToolFunctionCallback.java index ce001fd76..760b18c4d 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/AbstractToolFunctionCallback.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/AbstractToolFunctionCallback.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.model; +package org.springframework.ai.model.function; import java.util.function.Function; @@ -22,6 +22,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.util.Assert; /** @@ -112,6 +113,19 @@ public abstract class AbstractToolFunctionCallback implements Function AbstractToolFunctionCallback of(String name, String description, + Function function) { + Assert.notNull(name, "Name must not be null"); + Assert.notNull(description, "Description must not be null"); + Assert.notNull(function, "Function must not be null"); + + @SuppressWarnings("unchecked") + final Class inputClassType = (Class) TypeResolverHelper + .getFunctionInputClass((Class>) function.getClass()); + + return new DefaultToolFunctionCallback(name, description, inputClassType, function); + } + @Override public String getName() { return this.name; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/function/DefaultToolFunctionCallback.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/DefaultToolFunctionCallback.java new file mode 100644 index 000000000..584364b79 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/DefaultToolFunctionCallback.java @@ -0,0 +1,50 @@ +package org.springframework.ai.model.function; + +import java.util.function.Function; + +import org.springframework.util.Assert; + +/** + * 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 class DefaultToolFunctionCallback extends AbstractToolFunctionCallback { + + private Function function; + + public DefaultToolFunctionCallback(String name, String description, Class inputType, Function function) { + super(name, description, inputType); + Assert.notNull(function, "Function must not be null"); + this.function = function; + } + + public DefaultToolFunctionCallback(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; + } + + public DefaultToolFunctionCallback(String name, String description, Function function) { + this(name, description, resolveInputType(function), function); + } + + public DefaultToolFunctionCallback(String name, String description, Function responseConverter, + Function function) { + this(name, description, resolveInputType(function), responseConverter, function); + } + + @SuppressWarnings("unchecked") + private static Class resolveInputType(Function function) { + return (Class) TypeResolverHelper.getFunctionInputClass((Class>) function.getClass()); + } + + @Override + public O apply(I input) { + return this.function.apply(input); + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/function/SpringAiFunctionContextManager.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/SpringAiFunctionContextManager.java new file mode 100644 index 000000000..d7aa41e0a --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/SpringAiFunctionContextManager.java @@ -0,0 +1,115 @@ +/* + * 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.model.function; + +import java.lang.reflect.Type; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonClassDescription; + +import org.springframework.beans.BeansException; +import org.springframework.cloud.function.context.catalog.FunctionTypeUtils; +import org.springframework.cloud.function.context.config.FunctionContextUtils; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.Description; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +/** + * A Spring {@link ApplicationContextAware} implementation that provides a way to retrieve + * a {@link Function} from the Spring context and wrap it into a + * {@link ToolFunctionCallback}. + * + * The name of the function is determined by the bean name. + * + * The description of the function is determined by the following rules: + *
    + *
  • Provided as a default description
  • + *
  • Provided as a {@code @Description} annotation on the bean
  • + *
  • Provided as a {@code @JsonClassDescription} annotation on the input class
  • + *
+ * + * @author Christian Tzolov + * @author Christopher Smith + */ +public class SpringAiFunctionContextManager implements ApplicationContextAware { + + private GenericApplicationContext applicationContext; + + @Override + public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException { + this.applicationContext = (GenericApplicationContext) applicationContext; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + public ToolFunctionCallback getFunctionFromBean(@NonNull String beanName, @Nullable String defaultDescription) { + + Type beanType = FunctionContextUtils.findType(this.applicationContext.getBeanFactory(), beanName); + + if (beanType == null) { + throw new IllegalArgumentException( + "Functional bean with name: " + beanName + " does not exist in the context."); + } + + if (!Function.class.isAssignableFrom(FunctionTypeUtils.getRawType(beanType))) { + throw new IllegalArgumentException( + "Function call Bean must be of type Function. Found: " + beanType.getTypeName()); + } + + Type functionInputType = TypeResolverHelper.getFunctionArgumentType(beanType, 0); + + Class functionInputClass = FunctionTypeUtils.getRawType(functionInputType); + String functionName = beanName; + String functionDescription = defaultDescription; + + if (!StringUtils.hasText(functionDescription)) { + // Look for a Description annotation on the bean + Description descriptionAnnotation = applicationContext.findAnnotationOnBean(beanName, Description.class); + + if (descriptionAnnotation != null) { + functionDescription = descriptionAnnotation.value(); + } + + if (!StringUtils.hasText(functionDescription)) { + // Look for a JsonClassDescription annotation on the input class + JsonClassDescription jsonClassDescriptionAnnotation = functionInputClass + .getAnnotation(JsonClassDescription.class); + if (jsonClassDescriptionAnnotation != null) { + functionDescription = jsonClassDescriptionAnnotation.value(); + } + } + + if (!StringUtils.hasText(functionDescription)) { + throw new IllegalStateException("Could not determine function description." + + "Please provide a description either as a default parameter, via @Description annotation on the bean " + + "or @JsonClassDescription annotation on the input class."); + } + } + + Object bean = this.applicationContext.getBean(beanName); + + if (bean instanceof Function function) { + return new DefaultToolFunctionCallback(functionName, functionDescription, functionInputClass, function); + } + else { + throw new IllegalArgumentException("Bean must be of type Function"); + } + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/ToolFunctionCallback.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/ToolFunctionCallback.java similarity index 97% rename from spring-ai-core/src/main/java/org/springframework/ai/model/ToolFunctionCallback.java rename to spring-ai-core/src/main/java/org/springframework/ai/model/function/ToolFunctionCallback.java index 6a73c6798..9907431ab 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/ToolFunctionCallback.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/ToolFunctionCallback.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.model; +package org.springframework.ai.model.function; /** * Represents a model function call handler. Implementations are registered with the diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java new file mode 100644 index 000000000..b56b99890 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java @@ -0,0 +1,87 @@ +/* + * 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.model.function; + +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.function.Function; + +import net.jodah.typetools.TypeResolver; + +/** + * @author Christian Tzolov + */ +public class TypeResolverHelper { + + public static Class getFunctionInputClass(Class> functionClass) { + return getFunctionArgumentClass(functionClass, 0); + } + + public static Class getFunctionOutputClass(Class> functionClass) { + return getFunctionArgumentClass(functionClass, 1); + } + + public static Class getFunctionArgumentClass(Class> functionClass, int argumentIndex) { + Type type = TypeResolver.reify(Function.class, functionClass); + + var argumentType = type instanceof ParameterizedType + ? ((ParameterizedType) type).getActualTypeArguments()[argumentIndex] : Object.class; + + return toRawClass(argumentType); + } + + public static Type getFunctionInputType(Class> functionClass) { + return getFunctionArgumentType(functionClass, 0); + } + + public static Type getFunctionOutputType(Class> functionClass) { + return getFunctionArgumentType(functionClass, 1); + } + + public static Type getFunctionArgumentType(Class> functionClass, int argumentIndex) { + Type functionType = TypeResolver.reify(Function.class, functionClass); + return getFunctionArgumentType(functionType, argumentIndex); + } + + public static Type getFunctionArgumentType(Type functionType, int argumentIndex) { + var argumentType = functionType instanceof ParameterizedType + ? ((ParameterizedType) functionType).getActualTypeArguments()[argumentIndex] : Object.class; + + return argumentType; + } + + /** + * Effectively converts {@link Type} which could be {@link ParameterizedType} to raw + * Class (no generics). + * @param type actual {@link Type} instance + * @return instance of {@link Class} as raw representation of the provided + * {@link Type} + */ + public static Class toRawClass(Type type) { + return type != null + ? TypeResolver.resolveRawClass(type instanceof GenericArrayType ? type : TypeResolver.reify(type), null) + : null; + } + + // public static void main(String[] args) { + // Class> clazz = MockWeatherService.class; + // System.out.println(getFunctionInputType(clazz)); + + // } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperTests.java new file mode 100644 index 000000000..dacaf8bc9 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperTests.java @@ -0,0 +1,91 @@ +/* + * 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.model.function; + +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.model.function.TypeResolverHelperTests.MockWeatherService.Request; +import org.springframework.ai.model.function.TypeResolverHelperTests.MockWeatherService.Response; + +import static org.assertj.core.api.Assertions.assertThat;; + +/** + * @author Christian Tzolov + */ +public class TypeResolverHelperTests { + + @Test + public void testGetFunctionInputType() { + Class inputType = TypeResolverHelper.getFunctionInputClass(MockWeatherService.class); + assertThat(inputType).isEqualTo(Request.class); + } + + @Test + public void testGetFunctionOutputType() { + Class outputType = TypeResolverHelper.getFunctionOutputClass(MockWeatherService.class); + assertThat(outputType).isEqualTo(Response.class); + } + + @Test + public void testGetFunctionInputTypeForInstance() { + MockWeatherService service = new MockWeatherService(); + Class inputType = TypeResolverHelper.getFunctionInputClass(service.getClass()); + assertThat(inputType).isEqualTo(Request.class); + } + + public static class OutputFunctionConverter implements Function { + + @Override + public String apply(Response response) { + return response.temp + " " + response.unit; + } + + } + + public static class MockWeatherService implements Function { + + /** + * Weather Function request. + */ + @JsonInclude(Include.NON_NULL) + @JsonClassDescription("Weather API request") + public record Request(@JsonProperty(required = true, + value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location, + @JsonProperty(required = true, value = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @JsonProperty(required = true, + value = "unit") @JsonPropertyDescription("Temperature unit") String unit) { + } + + public record Response(double temp, String unit) { + } + + @Override + public Response apply(Request request) { + return new Response(10, "C"); + } + + } + +} 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 669a9b246..b0cd2af60 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 @@ -1,15 +1,19 @@ = Function Calling You can register custom Java functions with the `OpenAiChatClient` and have the OpenAI model intelligently choose to output a JSON object containing arguments to call one or many of the registered functions. -This is a powerful technique to connect the LLM capabilities with external tools and APIs. -The models have been trained to detect when a function should to be called and to respond with JSON that adheres to the function signature. +This allows you to connect the LLM capabilities with external tools and APIs. +The OpenAI models are trained to detect when a function should to be called and to respond with JSON that adheres to the function signature. -Note that the OpenAI API does not call the function directly; instead, the model generates JSON that you can use to call the function in your code and return the result back to the model to complete the conversation. +The OpenAI API does not call the function directly; instead, the model generates JSON that you can use to call the function in your code and return the result back to the model to complete the conversation. + +Spring AI provides flexible and user-friendly ways to register and call custom functions. +In general the custom functions need to provide a function `name`, function `description` that helps the model to understand when to call the function, and the function call `signature` (as JSON schema) to let the model know what arguments the function expects. -To register your custom function you need to specify a function `name`, function `description` that helps the model to understand when to call the function, and the function call `signature` (as JSON schema) to let the model know what arguments the function expects. Then you can implement a function that takes the function call arguments from the model interacts with the external, 3rd party, services and returns the result back to the model. -Spring AI offers a generic link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/ToolFunctionCallback.java[ToolFunctionCallback.java] interface and the companion link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/AbstractToolFunctionCallback.java[AbstractToolFunctionCallback.java] utility class to simplify the implementation and registration of Java callback functions. +Spring AI offers a generic link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/function/ToolFunctionCallback.java[ToolFunctionCallback.java] interface and the companion link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/function/DefaultToolFunctionCallback.java[DefauttToolFunctionCallback.java] utility class to simplify the implementation and registration of Java callback functions. + +Additionally the Auto-Configuration provides a way to auto-register any Function beans definition as function calling candidates in the `ChatClient`. == Quick Start @@ -34,52 +38,37 @@ public class MockWeatherService implements Function { } ---- -Then extend link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/AbstractToolFunctionCallback.java[AbstractToolFunctionCallback] to implement our weather function like this: - -[source,java] ----- -public class WeatherFunctionCallback - extends AbstractToolFunctionCallback { - - private final MockWeatherService weatherService = new MockWeatherService(); - - public WeatherFunctionCallback(String name, String description, Class inputType) { - super(name, // (1) - description, // (2) - inputType, // (3) - (response) -> "" + response.temp() + response.unit()); // (4) - } - - @Override - public Response apply(Request request) { - return this.weatherService.apply(request); - } -}; ----- - -The constructor takes a function name (1), description (2), input type signature (3) and a converter (4) to convert the `Response` into a text. -The Spring AI auto-generates the JSON Scheme for the `MockWeatherService.Request.class` signature. - === Registering Functions as Beans -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: +With the link:../openai-chat.html#_auto_configuration[OpenAiChatClient Auto-Configuration] you have multiple ways to register custom functions as beans in the Spring context. + +==== DefaultToolFunctionCallback Wrapper + +One way to register a function is to create `DefaultToolFunctionCallback` wrapper like this: [source,java] ---- @Configuration static class Config { + @Bean - public WeatherFunctionCallback weatherFunctionInfo() { - return new WeatherFunctionCallback( - "CurrentWeather", // (1) function name - "Get the weather in location", // (2) function description - MockWeatherService.Request.class); // (3) function input signature + public ToolFunctionCallback weatherFunctionInfo() { + + return new DefaultToolFunctionCallback<>("CurrentWeather", // (1) function name + "Get the weather in location", // (2) function description + (response) -> "" + response.temp() + response.unit(), // (3) Response Converter + new MockWeatherService()); // function code } ... } ---- -Now you can enable the `CurrentWeather` function in your prompt calls: +It wraps the 3rd party, `MockWeatherService` function and registers it as a `CurrentWeather` function with the `OpenAiChatClient`. +It also provides a description (2) and an optional response converter (3) to convert the response into a text as expected by the model. + +NOTE: The `DefaultToolFunctionCallback` internally resolves the function call signature based on the `MockWeatherService.Request` class. + +To let the model know and call your `CurrentWeather` function you need to enable it in your prompt requests: [source,java] ---- @@ -93,7 +82,7 @@ ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), logger.info("Response: {}", response); ---- -NOTE: you must enable, explicitly, the functions to be used in the prompt request using the `OpenAiChatOptions.builder().withEnabledFunction(...)` method (1). +NOTE: You can can have multiple functions registered in your `ChatClient` but only those enabled in the prompt request will be considered for the function calling. Above user question will trigger 3 calls to `CurrentWeather` function (one for each city) and the final response will be something like this: @@ -104,36 +93,80 @@ Here is the current weather for the requested cities: - Paris, France: 15.0°C ---- -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. +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/ToolCallWithDefaultToolFunctionCallbackIT.java[ToolCallWithDefaultToolFunctionCallbackIT.java] test demo this approach. -==== @SpringAiFunction -You can use the `SpringAiFunction` annotation cam be used to register a `java.util.Function` as a `ToolFunctionCallback` bean: +==== Plain Java Functions + +Instead of creating a `DefaultToolFunctionCallback` wrapper you can register any plain `java.util.Function` as a function calling candidate in the `ChatClient`: + +You just need to list the function bean names via the `spring.ai.openai.chat.options.beanFunctions.` property. + +NOTE: Each bean name should be specified in a separate property. + +For example lets register the `CurrentWeather1` function: + +---- +spring.ai.openai.chat.options.beanFunctions.CurrentWeather1 +---- [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() { + @Bean("CurrentWeather1") // (1) use the bean alias as function name. + @Description("Get the weather in location") // (2) function description + public Function weatherFunction1() { 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. +The `@Description` annotation is optional and provides a function description (2) that helps the model to understand when to call the function. -NOTE: The `SpringAiFunction` annotation supported only if the auto-configuration is enabled. +Instead of using the `@Description` annotation you can also provide the function description via the `spring.ai.openai.chat.options.beanFunctions.=` property: -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. +---- +spring.ai.openai.chat.options.beanFunctions.currentWeather2=Get the weather in location +---- + +[source,java] +---- +@Configuration +static class Config { + + @Bean + public Function currentWeather2() { // (1) bean name as function name. + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + ... +} +---- + +Another options is to use the `JacksonDescription` annotation on the `MockWeatherService.Request` to provide the function description: + +[source,java] +---- + +@Configuration +static class Config { + + @Bean + public Function currentWeather3() { // (1) bean name as function name. + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + ... +} + +@JsonClassDescription("Get the weather in location") // (2) function description +public record Request(String location, Unit unit) {} + +---- === Register/Call Functions with Prompt Options @@ -146,10 +179,10 @@ OpenAiChatClient chatClient = ... UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); var promptOptions = OpenAiChatOptions.builder() - .withToolCallbacks(List.of(new WeatherFunctionCallback( - "CurrentWeather", - "Get the weather in location", - MockWeatherService.Request.class))) + .withToolCallbacks(List.of(new DefaultToolFunctionCallback<>( + "CurrentWeather", // name + "Get the weather in location", // function description + new MockWeatherService()))) // function code .build(); ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), promptOptions)); diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/openai-chat.adoc index e0c3c16c2..02f0f3dd4 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/openai-chat.adoc @@ -73,6 +73,8 @@ The prefix `spring.ai.openai.chat` is the property prefix that lets you configur | spring.ai.openai.chat.options.tools | A list of tools the model may call. Currently, only functions are supported as a tool. Use this to provide a list of functions the model may generate JSON inputs for. | - | spring.ai.openai.chat.options.toolChoice | Controls which (if any) function is called by the model. none means the model will not call a function and instead generates a message. auto means the model can pick between generating a message or calling a function. Specifying a particular function via {"type: "function", "function": {"name": "my_function"}} forces the model to call that function. none is the default when no functions are present. auto is the default if functions are present. | - | spring.ai.openai.chat.options.user | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. | - +| spring.ai.openai.chat.options.enabledFunctions | List of functions, identified by their names, to enable for function calling in a single prompt requests. Functions with those names must exist in the toolCallbacks registry. | - +| spring.ai.openai.chat.options.beanFunctions.. | Map of bean names and their descriptions to register as function callbacks. For example `s.a.o.c.options.beanFunctions.weatherInfo` or with description `s.a.o.c.options.beanFunctions.weatherInfo=Get the weather in location`. The description is optional. Each bean name should be specified in a separate property. | - |==== NOTE: You can override the common `spring.ai.openai.base-url` and `spring.ai.openai.api-key` for the `ChatClient` and `EmbeddingClient` implementations. 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 deleted file mode 100644 index 812d5de53..000000000 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunction.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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 deleted file mode 100644 index a8f89fb8c..000000000 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/common/function/SpringAiFunctionAnnotationManager.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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 java.util.Functions 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 07d2e02c9..e973f13ef 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,9 +19,9 @@ 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.model.function.SpringAiFunctionContextManager; +import org.springframework.ai.model.function.ToolFunctionCallback; import org.springframework.ai.openai.OpenAiChatClient; import org.springframework.ai.openai.OpenAiEmbeddingClient; import org.springframework.ai.openai.OpenAiImageClient; @@ -58,7 +58,7 @@ public class OpenAiAutoConfiguration { @ConditionalOnMissingBean public OpenAiChatClient openAiChatClient(OpenAiConnectionProperties commonProperties, OpenAiChatProperties chatProperties, RestClient.Builder restClientBuilder, - List toolFunctionCallbacks, SpringAiFunctionAnnotationManager functionManager) { + List toolFunctionCallbacks, SpringAiFunctionContextManager functionManager) { String apiKey = StringUtils.hasText(chatProperties.getApiKey()) ? chatProperties.getApiKey() : commonProperties.getApiKey(); @@ -75,9 +75,11 @@ public class OpenAiAutoConfiguration { chatProperties.getOptions().getToolCallbacks().addAll(toolFunctionCallbacks); } - var annotatedFunctionsList = functionManager.getAnnotatedToolFunctionCallbacks(); - if (!CollectionUtils.isEmpty(annotatedFunctionsList)) { - chatProperties.getOptions().getToolCallbacks().addAll(annotatedFunctionsList); + if (!CollectionUtils.isEmpty(chatProperties.getOptions().getBeanFunctions())) { + chatProperties.getOptions().getBeanFunctions().forEach((beanName, description) -> { + ToolFunctionCallback function = functionManager.getFunctionFromBean(beanName, description); + chatProperties.getOptions().getToolCallbacks().add(function); + }); } return new OpenAiChatClient(openAiApi, chatProperties.getOptions()); @@ -122,8 +124,8 @@ public class OpenAiAutoConfiguration { @Bean @ConditionalOnMissingBean - public SpringAiFunctionAnnotationManager springAiFunctionManager(ApplicationContext context) { - SpringAiFunctionAnnotationManager manager = new SpringAiFunctionAnnotationManager(); + public SpringAiFunctionContextManager springAiFunctionManager(ApplicationContext context) { + SpringAiFunctionContextManager manager = new SpringAiFunctionContextManager(); 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/TollCallWithDefaultToolFunctionCallbackIT.java similarity index 78% rename from spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithSpringAIFunctionAnnotationIT.java rename to spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/TollCallWithDefaultToolFunctionCallbackIT.java index a6ca85bc6..607e28d94 100644 --- 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/TollCallWithDefaultToolFunctionCallbackIT.java @@ -17,31 +17,32 @@ 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.model.function.DefaultToolFunctionCallback; +import org.springframework.ai.model.function.ToolFunctionCallback; 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.Bean; import org.springframework.context.annotation.Configuration; import static org.assertj.core.api.Assertions.assertThat; @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") -class ToolCallWithSpringAIFunctionAnnotationIT { +public class TollCallWithDefaultToolFunctionCallbackIT { - private final Logger logger = LoggerFactory.getLogger(ToolCallWithBeanFunctionRegistrationIT.class); + private final Logger logger = LoggerFactory.getLogger(TollCallWithDefaultToolFunctionCallbackIT.class); private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY")) @@ -61,7 +62,7 @@ class ToolCallWithSpringAIFunctionAnnotationIT { logger.info("Response: {}", response); - assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + assertThat(response.getResult().getOutput().getContent()).contains("30.0", "10.0", "15.0"); }); } @@ -69,11 +70,13 @@ class ToolCallWithSpringAIFunctionAnnotationIT { @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); + @Bean + public ToolFunctionCallback weatherFunctionInfo() { + + return new DefaultToolFunctionCallback<>("WeatherInfo", // function name + "Get the weather in location", // function description + (response) -> "" + response.temp() + response.unit(), // responseConverter + new MockWeatherService()); // function code } } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithBeanFunctionRegistrationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithBeanFunctionRegistrationIT.java deleted file mode 100644 index 9cd0a456d..000000000 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithBeanFunctionRegistrationIT.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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 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.openai.tool.MockWeatherService.Request; -import org.springframework.ai.autoconfigure.openai.tool.MockWeatherService.Response; -import org.springframework.ai.chat.ChatResponse; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.model.AbstractToolFunctionCallback; -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.Bean; -import org.springframework.context.annotation.Configuration; - -import static org.assertj.core.api.Assertions.assertThat; - -@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") -public class ToolCallWithBeanFunctionRegistrationIT { - - 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.0", "10.0", "15.0"); - - }); - } - - @Configuration - static class Config { - - @Bean - public WeatherFunctionCallback weatherFunctionInfo() { - return new WeatherFunctionCallback("WeatherInfo", "Get the weather in location", - MockWeatherService.Request.class); - } - - public static class WeatherFunctionCallback - extends AbstractToolFunctionCallback { - - public WeatherFunctionCallback(String name, String description, Class inputType) { - super(name, description, inputType, (response) -> "" + response.temp() + response.unit()); - } - - private final MockWeatherService weatherService = new MockWeatherService(); - - @Override - public Response apply(Request request) { - return weatherService.apply(request); - } - - }; - - } - -} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithPlainBeanRegistrationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithPlainBeanRegistrationIT.java new file mode 100644 index 000000000..0e76768ee --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithPlainBeanRegistrationIT.java @@ -0,0 +1,116 @@ +/* + * 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.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.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +class ToolCallWithPlainBeanRegistrationIT { + + private final Logger logger = LoggerFactory.getLogger(ToolCallWithPlainBeanRegistrationIT.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 -> { + "spring.ai.openai.chat.options.beanFunctions.weatherFunction", + "spring.ai.openai.chat.options.beanFunctions.weatherFunction2=Get the weather in location", + "spring.ai.openai.chat.options.beanFunctions.weatherFunction3") + .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("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + + response = chatClient.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().withEnabledFunction("weatherFunction2").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + + response = chatClient.call(new Prompt(List.of(userMessage), + OpenAiChatOptions.builder().withEnabledFunction("weatherFunction3").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + @Bean(name = "weatherFunction2") + public Function weatherFunction1() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunction3() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithPromptFunctionRegistrationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithPromptFunctionRegistrationIT.java index 7f2ae3ef4..6af1f71e2 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithPromptFunctionRegistrationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/tool/ToolCallWithPromptFunctionRegistrationIT.java @@ -27,7 +27,7 @@ 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.model.AbstractToolFunctionCallback; +import org.springframework.ai.model.function.DefaultToolFunctionCallback; import org.springframework.ai.openai.OpenAiChatClient; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -54,18 +54,10 @@ public class ToolCallWithPromptFunctionRegistrationIT { UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); var promptOptions = OpenAiChatOptions.builder() - .withToolCallbacks(List - .of(new AbstractToolFunctionCallback( - "CurrentWeatherService", "Get the weather in location", MockWeatherService.Request.class, - (response) -> "" + response.temp() + response.unit()) { - - private final MockWeatherService weatherService = new MockWeatherService(); - - @Override - public MockWeatherService.Response apply(MockWeatherService.Request request) { - return weatherService.apply(request); - } - })) + .withToolCallbacks(List.of(new DefaultToolFunctionCallback<>("CurrentWeatherService", // name + "Get the weather in location", // function description + (response) -> "" + response.temp() + response.unit(), // responseConverter + new MockWeatherService()))) // function code .build(); ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), promptOptions));