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);