From 076726c1caa02323066a38357be4b93f0352e549 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 29 Feb 2024 12:30:21 +0100 Subject: [PATCH] Add Mistral AI Function Calling support - Make MistralAiChatClient extend the AbstractFunctionCallSupport and implement the necessary abstract classes. - Extend the MistralAiApi to include the latest (undocumented) changes providing function calling support as well. The Mistral AI is almost identical to the OpenAI API except it doesn't support parallel function colling (e.g. missing tool_call_id). - Add MistralAiApi function calling tests (implement the Mistral tutorial). - Extend the misral chat options to include the new API features and function call abstractions. - Extend Mistral's chat auto-configration to accomodate the function callback support. - Add ITs for testing function calling. - Remove redundant code from MistralAiApi and OpenAiApi. - Simplify and improve the HTTP error handling in OpenAiApi, ImageAiApi and MistralAiApi. --- .../ai/mistralai/MistralAiChatClient.java | 222 ++++++++--- .../ai/mistralai/MistralAiChatOptions.java | 141 ++++++- .../ai/mistralai/api/MistralAiApi.java | 364 +++++++++++++----- .../mistralai/MistralAiTestConfiguration.java | 2 +- .../MistralChatCompletionRequestTest.java | 2 - .../ai/mistralai/MistralEmbeddingIT.java | 6 +- .../tool/MistralAiApiToolFunctionCallIT.java | 166 ++++++++ .../api/tool/MockWeatherService.java | 91 +++++ .../tool/PaymentStatusFunctionCallingIT.java | 176 +++++++++ .../ai/openai/OpenAiChatClient.java | 6 +- .../ai/openai/OpenAiChatOptions.java | 18 +- .../ai/openai/api/OpenAiApi.java | 110 ++---- .../ai/openai/api/OpenAiImageApi.java | 12 +- .../api/tool/OpenAiApiToolFunctionCallIT.java | 5 +- .../ai/model/ModelOptionsUtils.java | 18 + spring-ai-spring-boot-autoconfigure/pom.xml | 17 +- .../mistralai/MistralAiAutoConfiguration.java | 24 +- .../mistralai/MistralAiChatProperties.java | 3 +- .../MistralAiEmbeddingProperties.java | 3 +- .../mistralai/tool/PaymentStatusBeanIT.java | 112 ++++++ .../tool/PaymentStatusBeanOpenAiIT.java | 119 ++++++ .../mistralai/tool/PaymentStatusPromptIT.java | 97 +++++ .../tool/WeatherServicePromptIT.java | 120 ++++++ .../openai/OpenAiPropertiesTests.java | 16 +- 24 files changed, 1569 insertions(+), 281 deletions(-) create mode 100644 models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/MistralAiApiToolFunctionCallIT.java create mode 100644 models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/MockWeatherService.java create mode 100644 models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/PaymentStatusFunctionCallingIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanOpenAiIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusPromptIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/WeatherServicePromptIT.java diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java index 609c8f90f..f924b8ad9 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java @@ -16,8 +16,10 @@ package org.springframework.ai.mistralai; import java.time.Duration; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; @@ -32,18 +34,29 @@ import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletion; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest; import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.function.AbstractFunctionCallSupport; +import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.http.ResponseEntity; import org.springframework.retry.RetryCallback; import org.springframework.retry.RetryContext; import org.springframework.retry.RetryListener; import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; /** * @author Ricken Bazolo + * @author Christian Tzolov * @since 0.8.1 */ -public class MistralAiChatClient implements ChatClient, StreamingChatClient { +public class MistralAiChatClient extends + AbstractFunctionCallSupport> + implements ChatClient, StreamingChatClient { private final Logger log = LoggerFactory.getLogger(getClass()); @@ -69,13 +82,6 @@ public class MistralAiChatClient implements ChatClient, StreamingChatClient { }) .build(); - public MistralAiChatClient(MistralAiApi mistralAiApi, MistralAiChatOptions options) { - Assert.notNull(mistralAiApi, "MistralAiApi must not be null"); - Assert.notNull(options, "Options must not be null"); - this.mistralAiApi = mistralAiApi; - this.defaultOptions = options; - } - public MistralAiChatClient(MistralAiApi mistralAiApi) { this(mistralAiApi, MistralAiChatOptions.builder() @@ -86,60 +92,41 @@ public class MistralAiChatClient implements ChatClient, StreamingChatClient { .build()); } - /** - * Accessible for testing. - */ - public MistralAiApi.ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { - var chatCompletionMessages = prompt.getInstructions() - .stream() - .map(m -> new MistralAiApi.ChatCompletionMessage(m.getContent(), - MistralAiApi.ChatCompletionMessage.Role.valueOf(m.getMessageType().name()))) - .toList(); + public MistralAiChatClient(MistralAiApi mistralAiApi, MistralAiChatOptions options) { + this(mistralAiApi, options, null); + } - var request = new MistralAiApi.ChatCompletionRequest(chatCompletionMessages, stream); - - if (this.defaultOptions != null) { - request = ModelOptionsUtils.merge(request, this.defaultOptions, MistralAiApi.ChatCompletionRequest.class); - } - - if (prompt.getOptions() != null) { - if (prompt.getOptions() instanceof ChatOptions runtimeOptions) { - var updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, ChatOptions.class, - MistralAiChatOptions.class); - request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, - MistralAiApi.ChatCompletionRequest.class); - } - else { - throw new IllegalArgumentException("Prompt options are not of type ChatOptions: " - + prompt.getOptions().getClass().getSimpleName()); - } - } - - return request; + public MistralAiChatClient(MistralAiApi mistralAiApi, MistralAiChatOptions options, + FunctionCallbackContext functionCallbackContext) { + super(functionCallbackContext); + Assert.notNull(mistralAiApi, "MistralAiApi must not be null"); + Assert.notNull(options, "Options must not be null"); + this.mistralAiApi = mistralAiApi; + this.defaultOptions = options; } @Override public ChatResponse call(Prompt prompt) { - return retryTemplate.execute(ctx -> { - var request = createRequest(prompt, false); + // return retryTemplate.execute(ctx -> { + var request = createRequest(prompt, false); - var completionEntity = this.mistralAiApi.chatCompletionEntity(request); + // var completionEntity = this.mistralAiApi.chatCompletionEntity(request); + ResponseEntity completionEntity = this.callWithFunctionSupport(request); - var chatCompletion = completionEntity.getBody(); - if (chatCompletion == null) { - log.warn("No chat completion returned for prompt: {}", prompt); - return new ChatResponse(List.of()); - } + var chatCompletion = completionEntity.getBody(); + if (chatCompletion == null) { + log.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } - List generations = chatCompletion.choices() - .stream() - .map(choice -> new Generation(choice.message().content(), - Map.of("role", choice.message().role().name())) - .withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null))) - .toList(); + List generations = chatCompletion.choices() + .stream() + .map(choice -> new Generation(choice.message().content(), Map.of("role", choice.message().role().name())) + .withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null))) + .toList(); - return new ChatResponse(generations); - }); + return new ChatResponse(generations); + // }); } @Override @@ -171,4 +158,133 @@ public class MistralAiChatClient implements ChatClient, StreamingChatClient { }); } + /** + * Accessible for testing. + */ + MistralAiApi.ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { + + Set functionsForThisRequest = new HashSet<>(); + + var chatCompletionMessages = prompt.getInstructions() + .stream() + .map(m -> new MistralAiApi.ChatCompletionMessage(m.getContent(), + MistralAiApi.ChatCompletionMessage.Role.valueOf(m.getMessageType().name()))) + .toList(); + + var request = new MistralAiApi.ChatCompletionRequest(chatCompletionMessages, stream); + + if (this.defaultOptions != null) { + Set defaultEnabledFunctions = this.handleFunctionCallbackConfigurations(this.defaultOptions, + !IS_RUNTIME_CALL); + + functionsForThisRequest.addAll(defaultEnabledFunctions); + + request = ModelOptionsUtils.merge(request, this.defaultOptions, MistralAiApi.ChatCompletionRequest.class); + } + + if (prompt.getOptions() != null) { + if (prompt.getOptions() instanceof ChatOptions runtimeOptions) { + var updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, ChatOptions.class, + MistralAiChatOptions.class); + + Set promptEnabledFunctions = this.handleFunctionCallbackConfigurations(updatedRuntimeOptions, + IS_RUNTIME_CALL); + functionsForThisRequest.addAll(promptEnabledFunctions); + + request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, + MistralAiApi.ChatCompletionRequest.class); + } + else { + throw new IllegalArgumentException("Prompt options are not of type ChatOptions: " + + prompt.getOptions().getClass().getSimpleName()); + } + } + + // Add the enabled functions definitions to the request's tools parameter. + if (!CollectionUtils.isEmpty(functionsForThisRequest)) { + + if (stream) { + throw new IllegalArgumentException("Currently tool functions are not supported in streaming mode"); + } + + request = ModelOptionsUtils.merge( + MistralAiChatOptions.builder().withTools(this.getFunctionTools(functionsForThisRequest)).build(), + request, ChatCompletionRequest.class); + } + + return request; + } + + private List getFunctionTools(Set functionNames) { + return this.resolveFunctionCallbacks(functionNames).stream().map(functionCallback -> { + var function = new MistralAiApi.FunctionTool.Function(functionCallback.getDescription(), + functionCallback.getName(), functionCallback.getInputTypeSchema()); + return new MistralAiApi.FunctionTool(function); + }).toList(); + } + + // + // Function Calling Support + // + @Override + protected ChatCompletionRequest doCreateToolResponseRequest(ChatCompletionRequest previousRequest, + ChatCompletionMessage responseMessage, List conversationHistory) { + + // Every tool-call item requires a separate function call and a response (TOOL) + // message. + for (ToolCall toolCall : responseMessage.toolCalls()) { + + var functionName = toolCall.function().name(); + String functionArguments = toolCall.function().arguments(); + + if (!this.functionCallbackRegister.containsKey(functionName)) { + throw new IllegalStateException("No function callback found for function name: " + functionName); + } + + String functionResponse = this.functionCallbackRegister.get(functionName).call(functionArguments); + + // Add the function response to the conversation. + conversationHistory + .add(new ChatCompletionMessage(functionResponse, ChatCompletionMessage.Role.TOOL, functionName, null)); + } + + // Recursively call chatCompletionWithTools until the model doesn't call a + // functions anymore. + ChatCompletionRequest newRequest = new ChatCompletionRequest(conversationHistory, previousRequest.stream()); + newRequest = ModelOptionsUtils.merge(newRequest, previousRequest, ChatCompletionRequest.class); + + return newRequest; + } + + @Override + protected List doGetUserMessages(ChatCompletionRequest request) { + return request.messages(); + } + + @Override + protected ChatCompletionMessage doGetToolResponseMessage(ResponseEntity chatCompletion) { + return chatCompletion.getBody().choices().iterator().next().message(); + } + + @Override + protected ResponseEntity doChatCompletion(ChatCompletionRequest request) { + return this.mistralAiApi.chatCompletionEntity(request); + } + + @Override + protected boolean isToolFunctionCall(ResponseEntity chatCompletion) { + + var body = chatCompletion.getBody(); + if (body == null) { + return false; + } + + var choices = body.choices(); + if (CollectionUtils.isEmpty(choices)) { + return false; + } + + return !CollectionUtils.isEmpty(choices.get(0).message().toolCalls()); + } + } diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java index ad8a08860..c47d95946 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java @@ -16,10 +16,22 @@ package org.springframework.ai.mistralai; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ResponseFormat; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.mistralai.api.MistralAiApi.FunctionTool; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.Assert; /** * @author Ricken Bazolo @@ -27,7 +39,7 @@ import org.springframework.ai.chat.prompt.ChatOptions; * @since 0.8.1 */ @JsonInclude(JsonInclude.Include.NON_NULL) -public class MistralAiChatOptions implements ChatOptions { +public class MistralAiChatOptions implements FunctionCallingOptions, ChatOptions { /** * ID of the model to use @@ -66,6 +78,55 @@ public class MistralAiChatOptions implements ChatOptions { */ private @JsonProperty("random_seed") Integer randomSeed; + /** + * An object specifying the format that the model must output. Setting to { "type": + * "json_object" } enables JSON mode, which guarantees the message the model generates + * is valid JSON. + */ + private @JsonProperty("response_format") ResponseFormat responseFormat; + + /** + * 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 function and instead generates a message. auto means the model can pick + * between generating a message or calling a function. + */ + @NestedConfigurationProperty + private @JsonProperty("tool_choice") ToolChoice toolChoice; + + /** + * MistralAI Tool Function Callbacks to register with the ChatClient. For Prompt + * Options the functionCallbacks are automatically enabled for the duration of the + * prompt execution. For Default Options the functionCallbacks 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 functionCallbacks = new ArrayList<>(); + + /** + * List of functions, identified by their names, to configure for function calling in + * the chat completion requests. Functions with those names must exist in the + * functionCallbacks registry. The {@link #functionCallbacks} from the PromptOptions + * are automatically enabled for the duration of the prompt execution. + * + * 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 + * functions is set in a prompt options, then the enabled functions are only active + * for the duration of this prompt execution. + */ + @NestedConfigurationProperty + @JsonIgnore + private Set functions = new HashSet<>(); + public static Builder builder() { return new Builder(); } @@ -104,6 +165,38 @@ public class MistralAiChatOptions implements ChatOptions { return this; } + public Builder withResponseFormat(ResponseFormat responseFormat) { + this.options.responseFormat = responseFormat; + return this; + } + + public Builder withTools(List tools) { + this.options.tools = tools; + return this; + } + + public Builder withToolChoice(ToolChoice toolChoice) { + this.options.toolChoice = toolChoice; + return this; + } + + public Builder withFunctionCallbacks(List functionCallbacks) { + this.options.functionCallbacks = functionCallbacks; + return this; + } + + public Builder withFunctions(Set functionNames) { + Assert.notNull(functionNames, "Function names must not be null"); + this.options.functions = functionNames; + return this; + } + + public Builder withFunction(String functionName) { + Assert.hasText(functionName, "Function name must not be empty"); + this.options.functions.add(functionName); + return this; + } + public MistralAiChatOptions build() { return this.options; } @@ -142,6 +235,30 @@ public class MistralAiChatOptions implements ChatOptions { this.randomSeed = randomSeed; } + public ResponseFormat getResponseFormat() { + return this.responseFormat; + } + + public void setResponseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public List getTools() { + return this.tools; + } + + public void setToolChoice(ToolChoice toolChoice) { + this.toolChoice = toolChoice; + } + + public ToolChoice getToolChoice() { + return this.toolChoice; + } + @Override public Float getTemperature() { return this.temperature; @@ -174,4 +291,26 @@ public class MistralAiChatOptions implements ChatOptions { throw new UnsupportedOperationException("Unsupported option: 'TopK'"); } + @Override + public List getFunctionCallbacks() { + return this.functionCallbacks; + } + + @Override + public void setFunctionCallbacks(List functionCallbacks) { + Assert.notNull(functionCallbacks, "FunctionCallbacks must not be null"); + this.functionCallbacks = functionCallbacks; + } + + @Override + public Set getFunctions() { + return this.functions; + } + + @Override + public void setFunctions(Set functions) { + Assert.notNull(functions, "Function must not be null"); + this.functions = functions; + } + } diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java index 0ed9df447..bfa326cc8 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java @@ -26,17 +26,19 @@ import java.util.function.Predicate; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.boot.context.properties.bind.ConstructorBinding; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StreamUtils; @@ -45,10 +47,15 @@ import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; /** - * Implementation of the MistralAI Embedding API: - * ... and Chat - * Completion API: - * ... + * Single-class, Java Client library for Mistral AI platform. Provides implementation for + * the MistralAI + * Embedding API and the + * Chat + * Completion APIs. + *

+ * Implements Synchronous and Streaming chat completion and supports latest + * Function Calling features. + *

* * @author Ricken Bazolo * @author Christian Tzolov @@ -101,12 +108,12 @@ public class MistralAiApi { var responseErrorHandler = new ResponseErrorHandler() { @Override - public boolean hasError(ClientHttpResponse response) throws IOException { + public boolean hasError(@NonNull ClientHttpResponse response) throws IOException { return response.getStatusCode().isError(); } @Override - public void handleError(ClientHttpResponse response) throws IOException { + public void handleError(@NonNull ClientHttpResponse response) throws IOException { if (response.getStatusCode().isError()) { String error = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); String message = String.format("%s - %s", response.getStatusCode().value(), error); @@ -151,6 +158,65 @@ public class MistralAiApi { } + /** + * Represents a tool the model may call. Currently, only functions are supported as a + * tool. + * + * @param type The type of the tool. Currently, only 'function' is supported. + * @param function The function definition. + */ + @JsonInclude(Include.NON_NULL) + public record FunctionTool(@JsonProperty("type") Type type, @JsonProperty("function") Function function) { + + /** + * Create a tool of type 'function' and the given function definition. + * @param function function definition. + */ + @ConstructorBinding + public FunctionTool(Function function) { + this(Type.FUNCTION, function); + } + + /** + * Create a tool of type 'function' and the given function definition. + */ + public enum Type { + + /** + * Function tool type. + */ + @JsonProperty("function") + FUNCTION + + } + + /** + * Function definition. + * + * @param description A description of what the function does, used by the model + * to choose when and how to call the function. + * @param name The name of the function to be called. Must be a-z, A-Z, 0-9, or + * contain underscores and dashes, with a maximum length of 64. + * @param parameters The parameters the functions accepts, described as a JSON + * Schema object. To describe a function that accepts no parameters, provide the + * value {"type": "object", "properties": {}}. + */ + public record Function(@JsonProperty("description") String description, @JsonProperty("name") String name, + @JsonProperty("parameters") Map parameters) { + + /** + * Create tool function definition. + * @param description tool function description. + * @param name tool function name. + * @param jsonSchema tool function schema as json. + */ + @ConstructorBinding + public Function(String description, String name, String jsonSchema) { + this(description, name, ModelOptionsUtils.jsonToMap(jsonSchema)); + } + } + } + /** * Usage statistics. * @@ -163,10 +229,10 @@ public class MistralAiApi { @JsonInclude(Include.NON_NULL) public record Usage( // @formatter:off - @JsonProperty("prompt_tokens") Integer promptTokens, - @JsonProperty("total_tokens") Integer totalTokens, - @JsonProperty("completion_tokens") Integer completionTokens) { - // @formatter:on + @JsonProperty("prompt_tokens") Integer promptTokens, + @JsonProperty("total_tokens") Integer totalTokens, + @JsonProperty("completion_tokens") Integer completionTokens) { + // @formatter:on } /** @@ -180,10 +246,10 @@ public class MistralAiApi { @JsonInclude(Include.NON_NULL) public record Embedding( // @formatter:off - @JsonProperty("index") Integer index, - @JsonProperty("embedding") List embedding, - @JsonProperty("object") String object) { - // @formatter:on + @JsonProperty("index") Integer index, + @JsonProperty("embedding") List embedding, + @JsonProperty("object") String object) { + // @formatter:on /** * Create an embedding with the given index, embedding and object type set to @@ -208,10 +274,10 @@ public class MistralAiApi { @JsonInclude(Include.NON_NULL) public record EmbeddingRequest( // @formatter:off - @JsonProperty("input") T input, - @JsonProperty("model") String model, - @JsonProperty("encoding_format") String encodingFormat) { - // @formatter:on + @JsonProperty("input") T input, + @JsonProperty("model") String model, + @JsonProperty("encoding_format") String encodingFormat) { + // @formatter:on /** * Create an embedding request with the given input, model and encoding format set @@ -245,11 +311,11 @@ public class MistralAiApi { @JsonInclude(Include.NON_NULL) public record EmbeddingList( // @formatter:off - @JsonProperty("object") String object, - @JsonProperty("data") List data, - @JsonProperty("model") String model, - @JsonProperty("usage") Usage usage) { - // @formatter:on + @JsonProperty("object") String object, + @JsonProperty("data") List data, + @JsonProperty("model") String model, + @JsonProperty("usage") Usage usage) { + // @formatter:on } /** @@ -298,6 +364,13 @@ public class MistralAiApi { * @param model ID of the model to use. * @param messages The prompt(s) to generate completions for, encoded as a list of * dict with role and content. The first prompt role should be user or system. + * @param 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. + * @param 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. Any + * means the model must call a function. * @param temperature What sampling temperature to use, between 0.0 and 1.0. Higher * values like 0.8 will make the output more random, while lower values like 0.2 will * make it more focused and deterministic. We generally recommend altering this or @@ -317,19 +390,25 @@ public class MistralAiApi { * @param safePrompt Whether to inject a safety prompt before all conversations. * @param randomSeed The seed to use for random sampling. If set, different calls will * generate deterministic results. + * @param responseFormat An object specifying the format that the model must output. + * Setting to { "type": "json_object" } enables JSON mode, which guarantees the + * message the model generates is valid JSON. */ @JsonInclude(Include.NON_NULL) public record ChatCompletionRequest( // @formatter:off - @JsonProperty("model") String model, - @JsonProperty("messages") List messages, - @JsonProperty("temperature") Float temperature, - @JsonProperty("top_p") Float topP, - @JsonProperty("max_tokens") Integer maxTokens, - @JsonProperty("stream") Boolean stream, - @JsonProperty("safe_prompt") Boolean safePrompt, - @JsonProperty("random_seed") Integer randomSeed) { - // @formatter:on + @JsonProperty("model") String model, + @JsonProperty("messages") List messages, + @JsonProperty("tools") List tools, + @JsonProperty("tool_choice") ToolChoice toolChoice, + @JsonProperty("temperature") Float temperature, + @JsonProperty("top_p") Float topP, + @JsonProperty("max_tokens") Integer maxTokens, + @JsonProperty("stream") Boolean stream, + @JsonProperty("safe_prompt") Boolean safePrompt, + @JsonProperty("random_seed") Integer randomSeed, + @JsonProperty("response_format") ResponseFormat responseFormat) { + // @formatter:on /** * Shortcut constructor for a chat completion request with the given messages and @@ -339,7 +418,7 @@ public class MistralAiApi { * @param model ID of the model to use. */ public ChatCompletionRequest(List messages, String model) { - this(model, messages, 0.7f, 1f, null, false, false, null); + this(model, messages, null, null, 0.7f, 1f, null, false, false, null, null); } /** @@ -354,7 +433,7 @@ public class MistralAiApi { */ public ChatCompletionRequest(List messages, String model, Float temperature, boolean stream) { - this(model, messages, temperature, 1f, null, stream, false, null); + this(model, messages, null, null, temperature, 1f, null, stream, false, null, null); } /** @@ -367,7 +446,22 @@ public class MistralAiApi { * */ public ChatCompletionRequest(List messages, String model, Float temperature) { - this(model, messages, temperature, 1f, null, false, false, null); + this(model, messages, null, null, temperature, 1f, null, false, false, null, null); + } + + /** + * Shortcut constructor for a chat completion request with the given messages, + * model, tools and tool choice. Streaming is set to false, temperature to 0.8 and + * all other parameters are null. + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param tools A list of tools the model may call. Currently, only functions are + * supported as a tool. + * @param toolChoice Controls which (if any) function is called by the model. + */ + public ChatCompletionRequest(List messages, String model, List tools, + ToolChoice toolChoice) { + this(model, messages, tools, toolChoice, null, 1f, null, false, false, null, null); } /** @@ -375,7 +469,31 @@ public class MistralAiApi { * stream. */ public ChatCompletionRequest(List messages, Boolean stream) { - this(null, messages, 0.7f, 1f, null, stream, false, null); + this(null, messages, null, null, 0.7f, 1f, null, stream, false, null, null); + } + + /** + * Specifies a tool the model should use. Use to force the model to call a + * specific function. + * + */ + public enum ToolChoice { + + // @formatter:off + @JsonProperty("auto") AUTO, + @JsonProperty("any") ANY, + @JsonProperty("none") NONE + // @formatter:on + + } + + /** + * An object specifying the format that the model must output. + * + * @param type Must be one of 'text' or 'json_object'. + */ + @JsonInclude(Include.NON_NULL) + public record ResponseFormat(@JsonProperty("type") String type) { } } @@ -385,13 +503,27 @@ public class MistralAiApi { * @param content The contents of the message. * @param role The role of the messages author. Could be one of the {@link Role} * types. + * @param toolCalls The tool calls generated by the model, such as function calls. + * Applicable only for {@link Role#ASSISTANT} role and null otherwise. */ @JsonInclude(Include.NON_NULL) public record ChatCompletionMessage( // @formatter:off - @JsonProperty("content") String content, - @JsonProperty("role") Role role) { - // @formatter:on + @JsonProperty("content") String content, + @JsonProperty("role") Role role, + @JsonProperty("name") String name, + @JsonProperty("tool_calls") List toolCalls) { + // @formatter:on + + /** + * Create a chat completion message with the given content and role. All other + * fields are null. + * @param content The contents of the message. + * @param role The role of the author of this message. + */ + public ChatCompletionMessage(String content, Role role) { + this(content, role, null, null); + } /** * The role of the author of this message. @@ -402,12 +534,39 @@ public class MistralAiApi { public enum Role { // @formatter:off - @JsonProperty("system") SYSTEM, - @JsonProperty("user") USER, - @JsonProperty("assistant") ASSISTANT - // @formatter:on + @JsonProperty("system") SYSTEM, + @JsonProperty("user") USER, + @JsonProperty("assistant") ASSISTANT, + @JsonProperty("tool") TOOL + // @formatter:on } + + /** + * The relevant tool call. + * + * @param id The ID of the tool call. This ID must be referenced when you submit + * the tool outputs in using the Submit tool outputs to run endpoint. + * @param type The type of tool call the output is required for. For now, this is + * always function. + * @param function The function definition. + */ + @JsonInclude(Include.NON_NULL) + public record ToolCall(@JsonProperty("id") String id, @JsonProperty("type") String type, + @JsonProperty("function") ChatCompletionFunction function) { + } + + /** + * The function definition. + * + * @param name The name of the function. + * @param arguments The arguments that the model expects you to pass to the + * function. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionFunction(@JsonProperty("name") String name, + @JsonProperty("arguments") String arguments) { + } } /** @@ -416,19 +575,19 @@ public class MistralAiApi { public enum ChatCompletionFinishReason { // @formatter:off - /** - * The model hit a natural stop point or a provided stop sequence. - */ - @JsonProperty("stop") STOP, - /** - * The maximum number of tokens specified in the request was reached. - */ - @JsonProperty("length") LENGTH, - /** - * The content was omitted due to a flag from our content filters. - */ - @JsonProperty("model_length") MODEL_LENGTH - // @formatter:on + /** + * The model hit a natural stop point or a provided stop sequence. + */ + @JsonProperty("stop") STOP, + /** + * The maximum number of tokens specified in the request was reached. + */ + @JsonProperty("length") LENGTH, + /** + * The content was omitted due to a flag from our content filters. + */ + @JsonProperty("model_length") MODEL_LENGTH + // @formatter:on } @@ -447,13 +606,13 @@ public class MistralAiApi { @JsonInclude(Include.NON_NULL) public record ChatCompletion( // @formatter:off - @JsonProperty("id") String id, - @JsonProperty("object") String object, - @JsonProperty("created") Long created, - @JsonProperty("model") String model, - @JsonProperty("choices") List choices, - @JsonProperty("usage") Usage usage) { - // @formatter:on + @JsonProperty("id") String id, + @JsonProperty("object") String object, + @JsonProperty("created") Long created, + @JsonProperty("model") String model, + @JsonProperty("choices") List choices, + @JsonProperty("usage") Usage usage) { + // @formatter:on /** * Chat completion choice. @@ -465,10 +624,10 @@ public class MistralAiApi { @JsonInclude(Include.NON_NULL) public record Choice( // @formatter:off - @JsonProperty("index") Integer index, - @JsonProperty("message") ChatCompletionMessage message, - @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason) { - // @formatter:on + @JsonProperty("index") Integer index, + @JsonProperty("message") ChatCompletionMessage message, + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason) { + // @formatter:on } } @@ -487,12 +646,12 @@ public class MistralAiApi { @JsonInclude(Include.NON_NULL) public record ChatCompletionChunk( // @formatter:off - @JsonProperty("id") String id, - @JsonProperty("object") String object, - @JsonProperty("created") Long created, - @JsonProperty("model") String model, - @JsonProperty("choices") List choices) { - // @formatter:on + @JsonProperty("id") String id, + @JsonProperty("object") String object, + @JsonProperty("created") Long created, + @JsonProperty("model") String model, + @JsonProperty("choices") List choices) { + // @formatter:on /** * Chat completion choice. @@ -504,26 +663,38 @@ public class MistralAiApi { @JsonInclude(Include.NON_NULL) public record ChunkChoice( // @formatter:off - @JsonProperty("index") Integer index, - @JsonProperty("delta") ChatCompletionMessage delta, - @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason) { - // @formatter:on + @JsonProperty("index") Integer index, + @JsonProperty("delta") ChatCompletionMessage delta, + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason) { + // @formatter:on } } /** * List of well-known Mistral chat models. * https://docs.mistral.ai/platform/endpoints/#mistral-ai-generative-models + * + *

+ * Mistral AI provides five API endpoints featuring five leading Large Language + * Models: + *

+ *
    + *
  • TINY - open-mistral-7b (aka mistral-tiny-2312)
  • + *
  • MIXTRAL - open-mixtral-8x7b (aka mistral-small-2312)
  • + *
  • SMALL_LATEST - mistral-small-latest (aka mistral-small-2402)
  • + *
  • MEDIUM - mistral-medium-latest (aka mistral-medium-2312)
  • + *
  • LARGE - mistral-large-latest (aka mistral-large-2402)
  • + *
*/ public enum ChatModel { // @formatter:off - @JsonProperty("mistral-tiny") TINY("mistral-tiny"), - @JsonProperty("mistral-small") SMALL("mistral-small"), - @JsonProperty("mistral-medium") MEDIUM("mistral-medium"), - @JsonProperty("mistral-large") LARGE("mistral-large"), - @JsonProperty("mistral-xlarge") XLARGE("mistral-xlarge"); - // @formatter:on + TINY("open-mistral-7b"), + MIXTRAL("open-mixtral-8x7b"), + SMALL("mistral-small-latest"), + MEDIUM("mistral-medium-latest"), + LARGE("mistral-large-latest"); + // @formatter:on private final String value; @@ -544,8 +715,8 @@ public class MistralAiApi { public enum EmbeddingModel { // @formatter:off - @JsonProperty("mistral-embed") EMBED("mistral-embed"); - // @formatter:on + @JsonProperty("mistral-embed") EMBED("mistral-embed"); + // @formatter:on private final String value; @@ -595,26 +766,7 @@ public class MistralAiApi { .bodyToFlux(String.class) .takeUntil(SSE_DONE_PREDICATE) .filter(SSE_DONE_PREDICATE.negate()) - .map(content -> parseJson(content, ChatCompletionChunk.class)); - } - - public static Map parseJson(String jsonSchema) { - try { - return new ObjectMapper().readValue(jsonSchema, new TypeReference>() { - }); - } - catch (Exception e) { - throw new MistralAiApiException("Failed to parse schema: " + jsonSchema, e); - } - } - - private T parseJson(String json, Class type) { - try { - return this.objectMapper.readValue(json, type); - } - catch (Exception e) { - throw new MistralAiApiException("Failed to parse schema: " + json, e); - } + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)); } } diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiTestConfiguration.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiTestConfiguration.java index 3c774d2eb..224095f94 100644 --- a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiTestConfiguration.java +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiTestConfiguration.java @@ -44,7 +44,7 @@ public class MistralAiTestConfiguration { @Bean public MistralAiChatClient mistralAiChatClient(MistralAiApi mistralAiApi) { return new MistralAiChatClient(mistralAiApi, - MistralAiChatOptions.builder().withModel(MistralAiApi.ChatModel.SMALL.getValue()).build()); + MistralAiChatOptions.builder().withModel(MistralAiApi.ChatModel.MIXTRAL.getValue()).build()); } } diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralChatCompletionRequestTest.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralChatCompletionRequestTest.java index 51c533627..df5b285d4 100644 --- a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralChatCompletionRequestTest.java +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralChatCompletionRequestTest.java @@ -20,8 +20,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.mistralai.MistralAiChatClient; -import org.springframework.ai.mistralai.MistralAiChatOptions; import org.springframework.ai.mistralai.api.MistralAiApi; import org.springframework.boot.test.context.SpringBootTest; diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralEmbeddingIT.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralEmbeddingIT.java index 7a3c6f498..f456c8516 100644 --- a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralEmbeddingIT.java +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralEmbeddingIT.java @@ -15,17 +15,15 @@ */ package org.springframework.ai.mistralai; +import java.util.List; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.embedding.EmbeddingRequest; -import org.springframework.ai.mistralai.MistralAiEmbeddingClient; -import org.springframework.ai.mistralai.MistralAiEmbeddingOptions; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import java.util.List; - import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/MistralAiApiToolFunctionCallIT.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/MistralAiApiToolFunctionCallIT.java new file mode 100644 index 000000000..9539c8b7e --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/MistralAiApiToolFunctionCallIT.java @@ -0,0 +1,166 @@ +/* + * 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.mistralai.api.tool; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Disabled; +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.mistralai.api.MistralAiApi; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletion; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest; +import org.springframework.ai.mistralai.api.MistralAiApi.FunctionTool.Type; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".+") +@Disabled +public class MistralAiApiToolFunctionCallIT { + + private final Logger logger = LoggerFactory.getLogger(MistralAiApiToolFunctionCallIT.class); + + MockWeatherService weatherService = new MockWeatherService(); + + static final String MISTRAL_AI_CHAT_MODEL = MistralAiApi.ChatModel.LARGE.getValue(); + + MistralAiApi completionApi = new MistralAiApi(System.getenv("MISTRAL_AI_API_KEY")); + + @Test + @SuppressWarnings("null") + public void toolFunctionCall() throws JsonProcessingException { + + // Step 1: send the conversation and available functions to the model + var message = new ChatCompletionMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Show the temperature in Celsius.", + Role.USER); + + var functionTool = new MistralAiApi.FunctionTool(Type.FUNCTION, + new MistralAiApi.FunctionTool.Function( + "Get the weather in location. Return temperature in 30°F or 30°C format.", "getCurrentWeather", + ModelOptionsUtils.jsonToMap(""" + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "enum": ["C", "F"] + } + }, + "required": ["location", "unit"] + } + """))); + + // Or you can use the + // ModelOptionsUtils.getJsonSchema(FakeWeatherService.Request.class))) to + // auto-generate the JSON schema like: + // var functionTool = new MistralAiApi.FunctionTool(Type.FUNCTION, new + // MistralAiApi.FunctionTool.Function( + // "Get the weather in location. Return temperature in 30°F or 30°C format.", + // "getCurrentWeather", + // ModelOptionsUtils.getJsonSchema(MockWeatherService.Request.class))); + + List messages = new ArrayList<>(List.of(message)); + + ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(messages, MISTRAL_AI_CHAT_MODEL, + List.of(functionTool), ToolChoice.AUTO); + + System.out + .println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(chatCompletionRequest)); + + ResponseEntity chatCompletion = completionApi.chatCompletionEntity(chatCompletionRequest); + + assertThat(chatCompletion.getBody()).isNotNull(); + assertThat(chatCompletion.getBody().choices()).isNotEmpty(); + + ChatCompletionMessage responseMessage = chatCompletion.getBody().choices().get(0).message(); + + assertThat(responseMessage.role()).isEqualTo(Role.ASSISTANT); + assertThat(responseMessage.toolCalls()).isNotNull(); + + // Check if the model wanted to call a function + if (responseMessage.toolCalls() != null) { + + // extend conversation with assistant's reply. + messages.add(responseMessage); + + // Send the info for each function call and function response to the model. + for (ToolCall toolCall : responseMessage.toolCalls()) { + var functionName = toolCall.function().name(); + if ("getCurrentWeather".equals(functionName)) { + MockWeatherService.Request weatherRequest = fromJson(toolCall.function().arguments(), + MockWeatherService.Request.class); + + MockWeatherService.Response weatherResponse = weatherService.apply(weatherRequest); + + // extend conversation with function response. + messages.add(new ChatCompletionMessage("" + weatherResponse.temp() + weatherRequest.unit(), + Role.TOOL, functionName, null)); + } + } + + var functionResponseRequest = new ChatCompletionRequest(messages, MISTRAL_AI_CHAT_MODEL, 0.8f); + + ResponseEntity chatCompletion2 = completionApi + .chatCompletionEntity(functionResponseRequest); + + logger.info("Final response: " + chatCompletion2.getBody()); + + assertThat(chatCompletion2.getBody().choices()).isNotEmpty(); + + assertThat(chatCompletion2.getBody().choices().get(0).message().role()).isEqualTo(Role.ASSISTANT); + assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("San Francisco") + .containsAnyOf("30.0°C", "30°C"); + assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("Tokyo") + .containsAnyOf("10.0°C", "10°C"); + ; + assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("Paris") + .containsAnyOf("15.0°C", "15°C"); + ; + } + + } + + private static T fromJson(String json, Class targetClass) { + try { + return new ObjectMapper().readValue(json, targetClass); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + +} \ No newline at end of file diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/MockWeatherService.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/MockWeatherService.java new file mode 100644 index 000000000..82dd94e1e --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/MockWeatherService.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.mistralai.api.tool; + +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; + +/** + * @author Christian Tzolov + */ +public 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 = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) { + } + + /** + * Temperature units. + */ + public enum Unit { + + /** + * Celsius. + */ + C("metric"), + /** + * Fahrenheit. + */ + F("imperial"); + + /** + * Human readable unit name. + */ + public final String unitName; + + private Unit(String text) { + this.unitName = text; + } + + } + + /** + * Weather Function response. + */ + public record Response(double temp, double feels_like, double temp_min, double temp_max, int pressure, int humidity, + Unit unit) { + } + + @Override + public Response apply(Request request) { + + double temperature = 0; + if (request.location().contains("Paris")) { + temperature = 15; + } + else if (request.location().contains("Tokyo")) { + temperature = 10; + } + else if (request.location().contains("San Francisco")) { + temperature = 30; + } + + return new Response(temperature, 15, 20, 2, 53, 45, Unit.C); + } + +} \ No newline at end of file diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/PaymentStatusFunctionCallingIT.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/PaymentStatusFunctionCallingIT.java new file mode 100644 index 000000000..65a60cb35 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/api/tool/PaymentStatusFunctionCallingIT.java @@ -0,0 +1,176 @@ +/* + * 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.mistralai.api.tool; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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.mistralai.api.MistralAiApi; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletion; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.mistralai.api.MistralAiApi.FunctionTool; +import org.springframework.ai.mistralai.api.MistralAiApi.FunctionTool.Type; +// import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Demonstrates how to use function calling suing Mistral AI Java API: + * {@link MistralAiApi}. + * + * It is based on the Mistral + * AI Function Calling guide. + * + * @author Christian Tzolov + * @since 0.8.1 + */ +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".+") +public class PaymentStatusFunctionCallingIT { + + private final Logger logger = LoggerFactory.getLogger(PaymentStatusFunctionCallingIT.class); + + // Assuming we have the following data + public static final Map DATA = Map.of("T1001", new StatusDate("Paid", "2021-10-05"), "T1002", + new StatusDate("Unpaid", "2021-10-06"), "T1003", new StatusDate("Paid", "2021-10-07"), "T1004", + new StatusDate("Paid", "2021-10-05"), "T1005", new StatusDate("Pending", "2021-10-08")); + + record StatusDate(String status, String date) { + } + + public record Transaction(@JsonProperty(required = true, value = "transaction_id") String transactionId) { + } + + public record Status(@JsonProperty(required = true, value = "status") String status) { + } + + public record Date(@JsonProperty(required = true, value = "date") String date) { + } + + private static class RetrievePaymentStatus implements Function { + + @Override + public Status apply(Transaction paymentTransaction) { + return new Status(DATA.get(paymentTransaction.transactionId).status); + } + + } + + private static class RetrievePaymentDate implements Function { + + @Override + public Date apply(Transaction paymentTransaction) { + return new Date(DATA.get(paymentTransaction.transactionId).date); + } + + } + + static Map> functions = Map.of("retrieve_payment_status", + new RetrievePaymentStatus(), "retrieve_payment_date", new RetrievePaymentDate()); + + @Test + @SuppressWarnings("null") + public void toolFunctionCall() throws JsonProcessingException { + + var transactionJsonSchema = """ + { + "type": "object", + "properties": { + "transaction_id": { + "type": "string", + "description": "The transaction id" + } + }, + "required": ["transaction_id"] + } + """; + + // Alternatively, generate the JSON schema using the ModelOptionsUtils helper: + // + // var transactionJsonSchema = ModelOptionsUtils.getJsonSchema(Transaction.class, + // false); + + var paymentStatusTool = new FunctionTool(Type.FUNCTION, new FunctionTool.Function( + "Get payment status of a transaction", "retrieve_payment_status", transactionJsonSchema)); + + var paymentDateTool = new FunctionTool(Type.FUNCTION, new FunctionTool.Function( + "Get payment date of a transaction", "retrieve_payment_date", transactionJsonSchema)); + + List messages = new ArrayList<>( + List.of(new ChatCompletionMessage("What's the status of my transaction with id T1001?", Role.USER))); + + MistralAiApi mistralApi = new MistralAiApi(System.getenv("MISTRAL_AI_API_KEY")); + + ResponseEntity response = mistralApi.chatCompletionEntity(new ChatCompletionRequest(messages, + MistralAiApi.ChatModel.LARGE.getValue(), List.of(paymentStatusTool, paymentDateTool), ToolChoice.AUTO)); + + ChatCompletionMessage responseMessage = response.getBody().choices().get(0).message(); + + assertThat(responseMessage.role()).isEqualTo(Role.ASSISTANT); + assertThat(responseMessage.toolCalls()).isNotNull(); + + // extend conversation with assistant's reply. + messages.add(responseMessage); + + // Send the info for each function call and function response to the model. + for (ToolCall toolCall : responseMessage.toolCalls()) { + + var functionName = toolCall.function().name(); + // Map the function, JSON arguments into a Transaction object. + Transaction transaction = jsonToObject(toolCall.function().arguments(), Transaction.class); + // Call the target function with the transaction object. + var result = functions.get(functionName).apply(transaction); + + // Extend conversation with function response. + // The functionName is used to identify the function response! + messages.add(new ChatCompletionMessage(result.toString(), Role.TOOL, functionName, null)); + } + + response = mistralApi + .chatCompletionEntity(new ChatCompletionRequest(messages, MistralAiApi.ChatModel.LARGE.getValue())); + + var responseContent = response.getBody().choices().get(0).message().content(); + logger.info("Final response: " + responseContent); + + assertThat(responseContent).containsIgnoringCase("T1001"); + assertThat(responseContent).containsIgnoringCase("Paid"); + } + + private static T jsonToObject(String json, Class targetClass) { + try { + return new ObjectMapper().readValue(json, targetClass); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + +} 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 b1fefe699..62f7bfe61 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 @@ -277,7 +277,8 @@ public class OpenAiChatClient extends String functionResponse = this.functionCallbackRegister.get(functionName).call(functionArguments); // Add the function response to the conversation. - conversationHistory.add(new ChatCompletionMessage(functionResponse, Role.TOOL, null, toolCall.id(), null)); + conversationHistory + .add(new ChatCompletionMessage(functionResponse, Role.TOOL, functionName, toolCall.id(), null)); } // Recursively call chatCompletionWithTools until the model doesn't call a @@ -291,7 +292,6 @@ public class OpenAiChatClient extends @Override protected List doGetUserMessages(ChatCompletionRequest request) { return request.messages(); - } @Override @@ -316,7 +316,7 @@ public class OpenAiChatClient extends return false; } - return choices.get(0).message().toolCalls() != null; + return !CollectionUtils.isEmpty(choices.get(0).message().toolCalls()); } } 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 b1f03c3c7..3ebb1c9db 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 @@ -31,7 +31,6 @@ import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallingOptions; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ResponseFormat; -import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ToolChoice; import org.springframework.ai.openai.api.OpenAiApi.FunctionTool; import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.util.Assert; @@ -52,7 +51,7 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { * Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing * frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. */ - private @JsonProperty("frequency_penalty") Float frequencyPenalty = 0.0f; + private @JsonProperty("frequency_penalty") Float frequencyPenalty; /** * Modify the likelihood of specified tokens appearing in the completion. Accepts a JSON object * that maps tokens (specified by their token ID in the tokenizer) to an associated bias value from -100 to 100. @@ -70,7 +69,7 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { * How many chat completion choices to generate for each input message. Note that you will be charged based * on the number of generated tokens across all of the choices. Keep n as 1 to minimize costs. */ - private @JsonProperty("n") Integer n = 1; + private @JsonProperty("n") Integer n; /** * Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they * appear in the text so far, increasing the model's likelihood to talk about new topics. @@ -98,7 +97,7 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { * more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend * altering this or top_p but not both. */ - private @JsonProperty("temperature") Float temperature = 0.8f; + private @JsonProperty("temperature") Float temperature; /** * An alternative to sampling with temperature, called nucleus sampling, where the model considers the * results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% @@ -116,10 +115,9 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { * 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. + * functions are present. Use the {@link ToolChoiceBuilder} to create a tool choice object. */ - @NestedConfigurationProperty - private @JsonProperty("tool_choice") ToolChoice toolChoice; + private @JsonProperty("tool_choice") String toolChoice; /** * A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. */ @@ -225,7 +223,7 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { return this; } - public Builder withToolChoice(ToolChoice toolChoice) { + public Builder withToolChoice(String toolChoice) { this.options.toolChoice = toolChoice; return this; } @@ -358,11 +356,11 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions { this.tools = tools; } - public ToolChoice getToolChoice() { + public String getToolChoice() { return this.toolChoice; } - public void setToolChoice(ToolChoice toolChoice) { + public void setToolChoice(String toolChoice) { this.toolChoice = toolChoice; } diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java index 37cc23abb..1f954f158 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java @@ -17,6 +17,7 @@ package org.springframework.ai.openai.api; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -25,20 +26,20 @@ import java.util.function.Predicate; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.boot.context.properties.bind.ConstructorBinding; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import org.springframework.util.StreamUtils; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; @@ -59,7 +60,6 @@ public class OpenAiApi { private final RestClient restClient; private final WebClient webClient; - private final ObjectMapper objectMapper; /** * Create an new chat completion api with base URL set to https://api.openai.com @@ -89,8 +89,6 @@ public class OpenAiApi { */ public OpenAiApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder) { - this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - Consumer jsonContentHeaders = headers -> { headers.setBearerAuth(openAiToken); headers.setContentType(MediaType.APPLICATION_JSON); @@ -99,19 +97,19 @@ public class OpenAiApi { var responseErrorHandler = new ResponseErrorHandler() { @Override - public boolean hasError(ClientHttpResponse response) throws IOException { + public boolean hasError(@NonNull ClientHttpResponse response) throws IOException { return response.getStatusCode().isError(); } @Override - public void handleError(ClientHttpResponse response) throws IOException { + public void handleError(@NonNull ClientHttpResponse response) throws IOException { if (response.getStatusCode().isError()) { + String error = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); + String message = String.format("%s - %s", response.getStatusCode().value(), error); if (response.getStatusCode().is4xxClientError()) { - throw new OpenAiApiClientErrorException(String.format("%s - %s", response.getStatusCode().value(), - OpenAiApi.this.objectMapper.readValue(response.getBody(), ResponseError.class))); + throw new OpenAiApiClientErrorException(message); } - throw new OpenAiApiException(String.format("%s - %s", response.getStatusCode().value(), - OpenAiApi.this.objectMapper.readValue(response.getBody(), ResponseError.class))); + throw new OpenAiApiException(message); } } }; @@ -129,6 +127,9 @@ public class OpenAiApi { } + /** + * Non HTTP Error related exceptions + */ public static class OpenAiApiException extends RuntimeException { public OpenAiApiException(String message) { @@ -157,29 +158,6 @@ public class OpenAiApi { } } - /** - * API error response. - * @param error Error details. - */ - @JsonInclude(Include.NON_NULL) - public record ResponseError(@JsonProperty("error") Error error) { - - /** - * Error details. - * @param message Error message. - * @param type Error type. - * @param param Error parameter. - * @param code Error code. - */ - @JsonInclude(Include.NON_NULL) - public record Error( - @JsonProperty("message") String message, - @JsonProperty("type") String type, - @JsonProperty("param") String param, - @JsonProperty("code") String code) { - } - } - /** * Represents a tool the model may call. Currently, only functions are supported as a tool. * @@ -234,7 +212,7 @@ public class OpenAiApi { */ @ConstructorBinding public Function(String description, String name, String jsonSchema) { - this(description, name, parseJson(jsonSchema)); + this(description, name, ModelOptionsUtils.jsonToMap(jsonSchema)); } } } @@ -278,7 +256,7 @@ public class OpenAiApi { * 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. + * functions are present. Use the {@link ToolChoiceBuilder} to create the tool choice value. * @param user A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. * */ @@ -298,7 +276,7 @@ public class OpenAiApi { @JsonProperty("temperature") Float temperature, @JsonProperty("top_p") Float topP, @JsonProperty("tools") List tools, - @JsonProperty("tool_choice") ToolChoice toolChoice, + @JsonProperty("tool_choice") String toolChoice, @JsonProperty("user") String user) { /** @@ -309,7 +287,7 @@ public class OpenAiApi { * @param temperature What sampling temperature to use, between 0 and 1. */ public ChatCompletionRequest(List messages, String model, Float temperature) { - this(messages, model, 0.0f, null, null, 1, 0.0f, + this(messages, model, null, null, null, null, null, null, null, null, false, temperature, null, null, null, null); } @@ -324,7 +302,7 @@ public class OpenAiApi { * as they become available, with the stream terminated by a data: [DONE] message. */ public ChatCompletionRequest(List messages, String model, Float temperature, boolean stream) { - this(messages, model, 0.0f, null, null, 1, 0.0f, + this(messages, model, null, null, null, null, null, null, null, null, stream, temperature, null, null, null, null); } @@ -339,8 +317,8 @@ public class OpenAiApi { * @param toolChoice Controls which (if any) function is called by the model. */ public ChatCompletionRequest(List messages, String model, - List tools, ToolChoice toolChoice) { - this(messages, model, 0.0f, null, null, 1, 0.0f, + List tools, String toolChoice) { + this(messages, model, null, null, null, null, null, null, null, null, false, 0.8f, null, tools, toolChoice, null); } @@ -360,23 +338,23 @@ public class OpenAiApi { } /** - * Specifies a tool the model should use. Use to force the model to call a specific function. - * - * @param type The type of the tool. Currently, only 'function' is supported. - * @param function single field map for type 'name':'your function name'. + * Helper factory that creates a tool_choice of type 'none', 'auto' or selected function by name. */ - @JsonInclude(Include.NON_NULL) - public record ToolChoice( - @JsonProperty("type") String type, - @JsonProperty("function") Map function) { + public static class ToolChoiceBuilder { + /** + * Model can pick between generating a message or calling a function. + */ + public static final String AUTO = "none"; + /** + * Model will not call a function and instead generates a message + */ + public static final String NONE = "none"; /** - * Create a tool choice of type 'function' and name 'functionName'. - * @param functionName Function name of the tool. + * Specifying a particular function forces the model to call that function. */ - @ConstructorBinding - public ToolChoice(String functionName) { - this("function", Map.of("name", functionName)); + public static String FUNCTION(String functionName) { + return ModelOptionsUtils.toJsonString(Map.of("type", "function", "function", Map.of("name", functionName))); } } @@ -676,7 +654,7 @@ public class OpenAiApi { .takeUntil(SSE_DONE_PREDICATE) // filters out the "[DONE]" message. .filter(SSE_DONE_PREDICATE.negate()) - .map(content -> parseJson(content, ChatCompletionChunk.class)); + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)); } /** @@ -795,25 +773,5 @@ public class OpenAiApi { .toEntity(new ParameterizedTypeReference<>() { }); } - - public static Map parseJson(String jsonSchema) { - try { - return new ObjectMapper().readValue(jsonSchema, - new TypeReference>() { - }); - } - catch (Exception e) { - throw new OpenAiApiException("Failed to parse schema: " + jsonSchema, e); - } - } - - private T parseJson(String json, Class type) { - try { - return this.objectMapper.readValue(json, type); - } - catch (Exception e) { - throw new OpenAiApiException("Failed to parse schema: " + json, e); - } - } } // @formatter:on diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java index 95939db71..92b3607cf 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java @@ -16,6 +16,7 @@ package org.springframework.ai.openai.api; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.function.Consumer; @@ -26,12 +27,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.ai.openai.api.OpenAiApi.OpenAiApiClientErrorException; import org.springframework.ai.openai.api.OpenAiApi.OpenAiApiException; -import org.springframework.ai.openai.api.OpenAiApi.ResponseError; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpResponse; import org.springframework.util.Assert; +import org.springframework.util.StreamUtils; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; @@ -78,13 +79,12 @@ public class OpenAiImageApi { @Override public void handleError(ClientHttpResponse response) throws IOException { if (response.getStatusCode().isError()) { + String error = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); + String message = String.format("%s - %s", response.getStatusCode().value(), error); if (response.getStatusCode().is4xxClientError()) { - throw new OpenAiApiClientErrorException(String.format("%s - %s", - response.getStatusCode().value(), - OpenAiImageApi.this.objectMapper.readValue(response.getBody(), ResponseError.class))); + throw new OpenAiApiClientErrorException(message); } - throw new OpenAiApiException(String.format("%s - %s", response.getStatusCode().value(), - OpenAiImageApi.this.objectMapper.readValue(response.getBody(), ResponseError.class))); + throw new OpenAiApiException(message); } } }; diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/api/tool/OpenAiApiToolFunctionCallIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/api/tool/OpenAiApiToolFunctionCallIT.java index 825c3f5f4..b213b35d2 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/api/tool/OpenAiApiToolFunctionCallIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/api/tool/OpenAiApiToolFunctionCallIT.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletion; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage; @@ -63,7 +64,7 @@ public class OpenAiApiToolFunctionCallIT { var functionTool = new OpenAiApi.FunctionTool(Type.FUNCTION, new OpenAiApi.FunctionTool.Function( "Get the weather in location. Return temperature in 30°F or 30°C format.", "getCurrentWeather", - OpenAiApi.parseJson(""" + ModelOptionsUtils.jsonToMap(""" { "type": "object", "properties": { @@ -129,7 +130,7 @@ public class OpenAiApiToolFunctionCallIT { // extend conversation with function response. messages.add(new ChatCompletionMessage("" + weatherResponse.temp() + weatherRequest.unit(), - Role.TOOL, null, toolCall.id(), null)); + Role.TOOL, functionName, toolCall.id(), null)); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java b/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java index bcd1e7974..7ca8e205f 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java @@ -30,6 +30,7 @@ import java.util.stream.Collectors; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -59,6 +60,7 @@ import org.springframework.util.CollectionUtils; public final class ModelOptionsUtils { private final static ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); private final static List BEAN_MERGE_FIELD_EXCISIONS = List.of("class"); @@ -88,6 +90,22 @@ public final class ModelOptionsUtils { private static TypeReference> MAP_TYPE_REF = new TypeReference>() { }; + /** + * Converts the given JSON string to an Object of the given type. + * @param the type of the object to return. + * @param json the JSON string to convert to an object. + * @param type the type of the object to return. + * @return Object instance of the given type. + */ + public static T jsonToObject(String json, Class type) { + try { + return OBJECT_MAPPER.readValue(json, type); + } + catch (Exception e) { + throw new RuntimeException("Failed to json: " + json, e); + } + } + /** * Converts the given object to a JSON string. * @param object the object to convert to a JSON string. diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index d2d1bfef6..19161a088 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -296,11 +296,18 @@ - org.testcontainers - qdrant - 1.19.6 - test - + org.testcontainers + qdrant + 1.19.6 + test + + + + org.skyscreamer + jsonassert + 1.5.0 + test + diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java index 376586eaa..cffaec366 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java @@ -16,18 +16,23 @@ package org.springframework.ai.autoconfigure.mistralai; -import org.springframework.ai.embedding.EmbeddingClient; +import java.util.List; + import org.springframework.ai.mistralai.MistralAiChatClient; import org.springframework.ai.mistralai.MistralAiEmbeddingClient; import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallbackContext; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.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.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; @@ -61,12 +66,17 @@ public class MistralAiAutoConfiguration { @ConditionalOnProperty(prefix = MistralAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true) public MistralAiChatClient mistralAiChatClient(MistralAiCommonProperties commonProperties, - MistralAiChatProperties chatProperties, RestClient.Builder restClientBuilder) { + MistralAiChatProperties chatProperties, RestClient.Builder restClientBuilder, + List toolFunctionCallbacks, FunctionCallbackContext functionCallbackContext) { var mistralAiApi = mistralAiApi(chatProperties.getApiKey(), commonProperties.getApiKey(), chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), restClientBuilder); - return new MistralAiChatClient(mistralAiApi, chatProperties.getOptions()); + if (!CollectionUtils.isEmpty(toolFunctionCallbacks)) { + chatProperties.getOptions().getFunctionCallbacks().addAll(toolFunctionCallbacks); + } + + return new MistralAiChatClient(mistralAiApi, chatProperties.getOptions(), functionCallbackContext); } private MistralAiApi mistralAiApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl, @@ -81,4 +91,12 @@ public class MistralAiAutoConfiguration { return new MistralAiApi(resoledBaseUrl, resolvedApiKey, restClientBuilder); } + @Bean + @ConditionalOnMissingBean + public FunctionCallbackContext springAiFunctionManager(ApplicationContext context) { + FunctionCallbackContext manager = new FunctionCallbackContext(); + manager.setApplicationContext(context); + return manager; + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java index 3578d7a9c..47e3dc027 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java @@ -17,6 +17,7 @@ package org.springframework.ai.autoconfigure.mistralai; import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; @@ -30,7 +31,7 @@ public class MistralAiChatProperties extends MistralAiParentProperties { public static final String CONFIG_PREFIX = "spring.ai.mistral.chat"; - public static final String DEFAULT_CHAT_MODEL = "mistral-tiny"; + public static final String DEFAULT_CHAT_MODEL = MistralAiApi.ChatModel.TINY.getValue(); private static final Double DEFAULT_TEMPERATURE = 0.7; diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java index 79ab498c5..5c46ec6f7 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java @@ -18,6 +18,7 @@ package org.springframework.ai.autoconfigure.mistralai; import org.springframework.ai.document.MetadataMode; import org.springframework.ai.mistralai.MistralAiEmbeddingOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; @@ -30,7 +31,7 @@ public class MistralAiEmbeddingProperties extends MistralAiParentProperties { public static final String CONFIG_PREFIX = "spring.ai.mistralai.embedding"; - public static final String DEFAULT_EMBEDDING_MODEL = "mistral-embed"; + public static final String DEFAULT_EMBEDDING_MODEL = MistralAiApi.EmbeddingModel.EMBED.getValue(); public static final String DEFAULT_ENCODING_FORMAT = "float"; diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanIT.java new file mode 100644 index 000000000..ffc5f4c0c --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanIT.java @@ -0,0 +1,112 @@ +/* + * 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.mistralai.tool; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonProperty; +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.mistralai.MistralAiAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.mistralai.MistralAiChatClient; +import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; +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 = "MISTRAL_AI_API_KEY", matches = ".*") +class PaymentStatusBeanIT { + + private final Logger logger = LoggerFactory.getLogger(PaymentStatusBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.apiKey=" + System.getenv("MISTRAL_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + + contextRunner + .withPropertyValues("spring.ai.mistral.chat.options.model=" + MistralAiApi.ChatModel.LARGE.getValue()) + .run(context -> { + + MistralAiChatClient chatClient = context.getBean(MistralAiChatClient.class); + + ChatResponse response = chatClient + .call(new Prompt(List.of(new UserMessage("What's the status of my transaction with id T1001?")), + MistralAiChatOptions.builder() + .withFunction("retrievePaymentStatus") + .withFunction("retrievePaymentDate") + .build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).containsIgnoringCase("T1001"); + assertThat(response.getResult().getOutput().getContent()).containsIgnoringCase("paid"); + }); + } + + // Assuming we have the following data + public static final Map DATA = Map.of("T1001", new StatusDate("Paid", "2021-10-05"), "T1002", + new StatusDate("Unpaid", "2021-10-06"), "T1003", new StatusDate("Paid", "2021-10-07"), "T1004", + new StatusDate("Paid", "2021-10-05"), "T1005", new StatusDate("Pending", "2021-10-08")); + + record StatusDate(String status, String date) { + } + + @Configuration + static class Config { + + public record Transaction(@JsonProperty(required = true, value = "transaction_id") String transactionId) { + } + + public record Status(@JsonProperty(required = true, value = "status") String status) { + } + + public record Date(@JsonProperty(required = true, value = "date") String date) { + } + + @Bean + @Description("Get payment status of a transaction") + public Function retrievePaymentStatus() { + return (transaction) -> new Status(DATA.get(transaction.transactionId).status()); + } + + @Bean + @Description("Get payment date of a transaction") + public Function retrievePaymentDate() { + return (transaction) -> new Date(DATA.get(transaction.transactionId).date()); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanOpenAiIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanOpenAiIT.java new file mode 100644 index 000000000..98bc1283e --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusBeanOpenAiIT.java @@ -0,0 +1,119 @@ +/* + * 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.mistralai.tool; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonProperty; +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.mistralai.api.MistralAiApi; +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; + +/** + * Same test as {@link PaymentStatusBeanIT.java} but using {@link OpenAiChatClient} for + * Mistral AI Function Calling implementation. + * + * @author Christian Tzolov + */ +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".*") +class PaymentStatusBeanOpenAiIT { + + private final Logger logger = LoggerFactory.getLogger(PaymentStatusBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("MISTRAL_AI_API_KEY"), + "spring.ai.openai.chat.base-url=https://api.mistral.ai") + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, OpenAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + + contextRunner + .withPropertyValues("spring.ai.openai.chat.options.model=" + MistralAiApi.ChatModel.SMALL.getValue()) + .run(context -> { + + OpenAiChatClient chatClient = context.getBean(OpenAiChatClient.class); + + ChatResponse response = chatClient + .call(new Prompt(List.of(new UserMessage("What's the status of my transaction with id T1001?")), + OpenAiChatOptions.builder() + .withFunction("retrievePaymentStatus") + .withFunction("retrievePaymentDate") + .build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).containsIgnoringCase("T1001"); + assertThat(response.getResult().getOutput().getContent()).containsIgnoringCase("paid"); + }); + } + + // Assuming we have the following data + public static final Map DATA = Map.of("T1001", new StatusDate("Paid", "2021-10-05"), "T1002", + new StatusDate("Unpaid", "2021-10-06"), "T1003", new StatusDate("Paid", "2021-10-07"), "T1004", + new StatusDate("Paid", "2021-10-05"), "T1005", new StatusDate("Pending", "2021-10-08")); + + record StatusDate(String status, String date) { + } + + @Configuration + static class Config { + + public record Transaction(@JsonProperty(required = true, value = "transaction_id") String transactionId) { + } + + public record Status(@JsonProperty(required = true, value = "status") String status) { + } + + public record Date(@JsonProperty(required = true, value = "date") String date) { + } + + @Bean + @Description("Get payment status of a transaction") + public Function retrievePaymentStatus() { + return (transaction) -> new Status(DATA.get(transaction.transactionId).status()); + } + + @Bean + @Description("Get payment date of a transaction") + public Function retrievePaymentDate() { + return (transaction) -> new Date(DATA.get(transaction.transactionId).date()); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusPromptIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusPromptIT.java new file mode 100644 index 000000000..77b89270e --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/PaymentStatusPromptIT.java @@ -0,0 +1,97 @@ +/* + * 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.mistralai.tool; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonProperty; +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.mistralai.MistralAiAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.mistralai.MistralAiChatClient; +import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.model.function.FunctionCallbackWrapper; +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 = "MISTRAL_AI_API_KEY", matches = ".*") +public class PaymentStatusPromptIT { + + private final Logger logger = LoggerFactory.getLogger(WeatherServicePromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.apiKey=" + System.getenv("MISTRAL_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class)); + + public record Transaction(@JsonProperty(required = true, value = "transaction_id") String id) { + } + + public record Status(@JsonProperty(required = true, value = "status") String status) { + } + + record StatusDate(String status, String date) { + } + + // Assuming we have the following payment data. + public static final Map DATA = Map.of(new Transaction("T1001"), + new StatusDate("Paid", "2021-10-05"), new Transaction("T1002"), new StatusDate("Unpaid", "2021-10-06"), + new Transaction("T1003"), new StatusDate("Paid", "2021-10-07"), new Transaction("T1004"), + new StatusDate("Paid", "2021-10-05"), new Transaction("T1005"), new StatusDate("Pending", "2021-10-08")); + + @Test + void functionCallTest() { + contextRunner + .withPropertyValues("spring.ai.mistral.chat.options.model=" + MistralAiApi.ChatModel.SMALL.getValue()) + .run(context -> { + + MistralAiChatClient chatClient = context.getBean(MistralAiChatClient.class); + + UserMessage userMessage = new UserMessage("What's the status of my transaction with id T1001?"); + + var promptOptions = MistralAiChatOptions.builder() + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new Function() { + public Status apply(Transaction transaction) { + return new Status(DATA.get(transaction).status()); + } + }) + .withName("retrievePaymentStatus") + .withDescription("Get payment status of a transaction") + .build())) + .build(); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).containsIgnoringCase("T1001"); + assertThat(response.getResult().getOutput().getContent()).containsIgnoringCase("paid"); + }); + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/WeatherServicePromptIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/WeatherServicePromptIT.java new file mode 100644 index 000000000..cea101cf2 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/tool/WeatherServicePromptIT.java @@ -0,0 +1,120 @@ +/* + * 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.mistralai.tool; + +import java.util.List; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +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.mistralai.MistralAiAutoConfiguration; +import org.springframework.ai.autoconfigure.mistralai.tool.WeatherServicePromptIT.MyWeatherService.Request; +import org.springframework.ai.autoconfigure.mistralai.tool.WeatherServicePromptIT.MyWeatherService.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.mistralai.MistralAiChatClient; +import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.model.function.FunctionCallbackWrapper; +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; + +/** + * @author Christian Tzolov + * @since 0.8.1 + */ +@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".*") +public class WeatherServicePromptIT { + + private final Logger logger = LoggerFactory.getLogger(WeatherServicePromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.mistralai.api-key=" + System.getenv("MISTRAL_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class)); + + @Test + void promptFunctionCall() { + contextRunner + .withPropertyValues("spring.ai.mistral.chat.options.model=" + MistralAiApi.ChatModel.LARGE.getValue()) + .run(context -> { + + MistralAiChatClient chatClient = context.getBean(MistralAiChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in Paris?"); + // UserMessage userMessage = new UserMessage("What's the weather like in + // San Francisco, Tokyo, and + // Paris?"); + + var promptOptions = MistralAiChatOptions.builder() + .withToolChoice(ToolChoice.AUTO) + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MyWeatherService()) + .withName("CurrentWeatherService") + .withDescription("Get the current weather in requested location") + .build())) + .build(); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).containsAnyOf("15", "15.0"); + // assertThat(response.getResult().getOutput().getContent()).contains("30.0", + // "10.0", "15.0"); + }); + } + + public static class MyWeatherService implements Function { + + // @formatter:off + public enum Unit { C, F } + + @JsonInclude(Include.NON_NULL) + public record Request( + @JsonProperty(required = true, value = "location") String location, + @JsonProperty(required = true, value = "unit") Unit unit) {} + + public record Response(double temperature, Unit unit) {} + // @formatter:on + + @Override + public Response apply(Request request) { + if (request.location().contains("Paris")) { + return new Response(15, request.unit()); + } + else if (request.location().contains("Tokyo")) { + return new Response(10, request.unit()); + } + else if (request.location().contains("San Francisco")) { + return new Response(30, request.unit()); + } + throw new IllegalArgumentException("Invalid request: " + request); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java index cc80bac4c..5c3237a12 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 the original author or authors. + * 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. @@ -16,15 +16,15 @@ package org.springframework.ai.autoconfigure.openai; -import java.util.Map; - import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; import org.springframework.ai.openai.OpenAiChatClient; import org.springframework.ai.openai.OpenAiEmbeddingClient; import org.springframework.ai.openai.OpenAiImageClient; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ResponseFormat; -import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ToolChoiceBuilder; import org.springframework.ai.openai.api.OpenAiApi.FunctionTool.Type; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; @@ -218,7 +218,8 @@ public class OpenAiPropertiesTests { "spring.ai.openai.chat.options.temperature=0.55", "spring.ai.openai.chat.options.topP=0.56", - "spring.ai.openai.chat.options.toolChoice.functionName=toolChoiceFunctionName", + // "spring.ai.openai.chat.options.toolChoice.functionName=toolChoiceFunctionName", + "spring.ai.openai.chat.options.toolChoice=" + ToolChoiceBuilder.FUNCTION("toolChoiceFunctionName"), "spring.ai.openai.chat.options.tools[0].function.name=myFunction1", "spring.ai.openai.chat.options.tools[0].function.description=function description", @@ -272,8 +273,9 @@ public class OpenAiPropertiesTests { assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f); assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56f); - assertThat(chatProperties.getOptions().getToolChoice()) - .isEqualTo(new ToolChoice("function", Map.of("name", "toolChoiceFunctionName"))); + JSONAssert.assertEquals("{\"type\":\"function\",\"function\":{\"name\":\"toolChoiceFunctionName\"}}", + chatProperties.getOptions().getToolChoice(), JSONCompareMode.LENIENT); + assertThat(chatProperties.getOptions().getUser()).isEqualTo("userXYZ"); assertThat(chatProperties.getOptions().getTools()).hasSize(1);