diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatModel.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatModel.java index b884584f4..257ea0264 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatModel.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatModel.java @@ -17,24 +17,33 @@ package org.springframework.ai.minimax; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.RateLimit; +import org.springframework.ai.chat.model.AbstractToolCallSupport; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.model.StreamingChatModel; -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.minimax.api.MiniMaxApi; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletion; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletion.Choice; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionChunk; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionFinishReason; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.Role; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.ToolCall; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionRequest; import org.springframework.ai.minimax.api.MiniMaxApi.FunctionTool; +import org.springframework.ai.minimax.metadata.MiniMaxUsage; import org.springframework.ai.model.ModelOptionsUtils; -import org.springframework.ai.model.function.AbstractFunctionCallSupport; +import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackContext; import org.springframework.ai.retry.RetryUtils; import org.springframework.http.ResponseEntity; @@ -42,12 +51,11 @@ import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -61,9 +69,7 @@ import java.util.concurrent.ConcurrentHashMap; * @see MiniMaxApi * @since 1.0.0 M1 */ -public class MiniMaxChatModel extends - AbstractFunctionCallSupport> - implements ChatModel, StreamingChatModel { +public class MiniMaxChatModel extends AbstractToolCallSupport implements ChatModel, StreamingChatModel { private static final Logger logger = LoggerFactory.getLogger(MiniMaxChatModel.class); @@ -113,10 +119,27 @@ public class MiniMaxChatModel extends */ public MiniMaxChatModel(MiniMaxApi miniMaxApi, MiniMaxChatOptions options, FunctionCallbackContext functionCallbackContext, RetryTemplate retryTemplate) { - super(functionCallbackContext); + this(miniMaxApi, options, functionCallbackContext, List.of(), retryTemplate); + } + + /** + * Initializes a new instance of the MiniMaxChatModel. + * @param miniMaxApi The MiniMaxApi instance to be used for interacting with the + * MiniMax Chat API. + * @param options The MiniMaxChatOptions to configure the chat model. + * @param functionCallbackContext The function callback context. + * @param toolFunctionCallbacks The tool function callbacks. + * @param retryTemplate The retry template. + */ + public MiniMaxChatModel(MiniMaxApi miniMaxApi, MiniMaxChatOptions options, + FunctionCallbackContext functionCallbackContext, List toolFunctionCallbacks, + RetryTemplate retryTemplate) { + super(functionCallbackContext, options, toolFunctionCallbacks); Assert.notNull(miniMaxApi, "MiniMaxApi must not be null"); Assert.notNull(options, "Options must not be null"); Assert.notNull(retryTemplate, "RetryTemplate must not be null"); + Assert.isTrue(CollectionUtils.isEmpty(options.getFunctionCallbacks()), + "The default function callbacks must be set via the toolFunctionCallbacks constructor parameter"); this.miniMaxApi = miniMaxApi; this.defaultOptions = options; this.retryTemplate = retryTemplate; @@ -124,94 +147,150 @@ public class MiniMaxChatModel extends @Override public ChatResponse call(Prompt prompt) { - ChatCompletionRequest request = createRequest(prompt, false); - return this.retryTemplate.execute(ctx -> { + ResponseEntity completionEntity = this.retryTemplate + .execute(ctx -> this.miniMaxApi.chatCompletionEntity(request)); - ResponseEntity completionEntity = this.callWithFunctionSupport(request); + var chatCompletion = completionEntity.getBody(); - var chatCompletion = completionEntity.getBody(); - if (chatCompletion == null) { - logger.warn("No chat completion returned for prompt: {}", prompt); - return new ChatResponse(List.of()); - } + if (chatCompletion == null) { + logger.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } - if (chatCompletion.baseResponse() != null && chatCompletion.baseResponse().statusCode() != 0) { - throw new RuntimeException(chatCompletion.baseResponse().message()); - } + List choices = chatCompletion.choices(); + if (choices == null) { + logger.warn("No choices returned for prompt: {}, because: {}}", prompt, + chatCompletion.baseResponse().message()); + return new ChatResponse(List.of()); + } - List generations = chatCompletion.choices().stream().map(choice -> { - return new Generation(choice.message().content(), toMap(chatCompletion.id(), choice)) - .withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null)); - }).toList(); + List generations = choices.stream().map(choice -> { + // @formatter:off + Map metadata = Map.of( + "id", chatCompletion.id(), + "role", choice.message().role() != null ? choice.message().role().name() : "", + "finishReason", choice.finishReason() != null ? choice.finishReason().name() : ""); + // @formatter:on + return buildGeneration(choice, metadata); + }).toList(); - return new ChatResponse(generations); - }); + ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody())); + + if (isToolCall(chatResponse, + Set.of(ChatCompletionFinishReason.TOOL_CALLS.name(), ChatCompletionFinishReason.STOP.name()))) { + var toolCallConversation = handleToolCalls(prompt, chatResponse); + // Recursively call the call method with the tool call message + // conversation that contains the call responses. + return this.call(new Prompt(toolCallConversation, prompt.getOptions())); + } + + return chatResponse; } - private Map toMap(String id, ChatCompletion.Choice choice) { - Map map = new HashMap<>(); - - var message = choice.message(); - if (message.role() != null) { - map.put("role", message.role().name()); - } - if (choice.finishReason() != null) { - map.put("finishReason", choice.finishReason().name()); - } - map.put("id", id); - return map; + @Override + public ChatOptions getDefaultOptions() { + return MiniMaxChatOptions.fromOptions(this.defaultOptions); } @Override public Flux stream(Prompt prompt) { - ChatCompletionRequest request = createRequest(prompt, true); - return this.retryTemplate.execute(ctx -> { + Flux completionChunks = this.retryTemplate + .execute(ctx -> this.miniMaxApi.chatCompletionStream(request)); - Flux completionChunks = this.miniMaxApi.chatCompletionStream(request); + // For chunked responses, only the first chunk contains the choice role. + // The rest of the chunks with same ID share the same role. + ConcurrentHashMap roleMap = new ConcurrentHashMap<>(); - // For chunked responses, only the first chunk contains the choice role. - // The rest of the chunks with same ID share the same role. - ConcurrentHashMap roleMap = new ConcurrentHashMap<>(); - - // Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse - // the function call handling logic. - return completionChunks.map(this::chunkToChatCompletion).map(chatCompletion -> { + // Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse + // the function call handling logic. + Flux chatResponse = completionChunks.map(this::chunkToChatCompletion) + .switchMap(chatCompletion -> Mono.just(chatCompletion).map(chatCompletion2 -> { try { - chatCompletion = handleFunctionCallOrReturn(request, ResponseEntity.of(Optional.of(chatCompletion))) - .getBody(); - @SuppressWarnings("null") - String id = chatCompletion.id(); + String id = chatCompletion2.id(); - List generations = chatCompletion.choices().stream().map(choice -> { - if (choice.message().role() != null) { - roleMap.putIfAbsent(id, choice.message().role().name()); - } - String finish = (choice.finishReason() != null ? choice.finishReason().name() : ""); - var generation = new Generation(choice.message().content(), - Map.of("id", id, "role", roleMap.get(id), "finishReason", finish)); - if (choice.finishReason() != null) { - generation = generation.withGenerationMetadata( - ChatGenerationMetadata.from(choice.finishReason().name(), null)); - } - return generation; - }).toList(); + // @formatter:off + List generations = chatCompletion2.choices().stream().map(choice -> { + if (choice.message().role() != null) { + roleMap.putIfAbsent(id, choice.message().role().name()); + } + Map metadata = Map.of( + "id", chatCompletion2.id(), + "role", roleMap.getOrDefault(id, ""), + "finishReason", choice.finishReason() != null ? choice.finishReason().name() : ""); + return buildGeneration(choice, metadata); + }).toList(); + // @formatter:on - return new ChatResponse(generations); + if (chatCompletion2.usage() != null) { + return new ChatResponse(generations, from(chatCompletion2)); + } + else { + return new ChatResponse(generations); + } } catch (Exception e) { logger.error("Error processing chat completion", e); return new ChatResponse(List.of()); } - }); + })); + + return chatResponse.flatMap(response -> { + + if (isToolCall(response, + Set.of(ChatCompletionFinishReason.TOOL_CALLS.name(), ChatCompletionFinishReason.STOP.name()))) { + var toolCallConversation = handleToolCalls(prompt, response); + // Recursively call the stream method with the tool call message + // conversation that contains the call responses. + return this.stream(new Prompt(toolCallConversation, prompt.getOptions())); + } + else { + return Flux.just(response); + } }); } + private ChatResponseMetadata from(ChatCompletion result, RateLimit rateLimit) { + Assert.notNull(result, "MiniMax ChatCompletionResult must not be null"); + return ChatResponseMetadata.builder() + .withId(result.id()) + .withUsage(MiniMaxUsage.from(result.usage())) + .withModel(result.model()) + .withRateLimit(rateLimit) + .withKeyValue("created", result.created()) + .build(); + } + + private ChatResponseMetadata from(ChatCompletion result) { + Assert.notNull(result, "MiniMax ChatCompletionResult must not be null"); + return ChatResponseMetadata.builder() + .withId(result.id()) + .withUsage(MiniMaxUsage.from(result.usage())) + .withModel(result.model()) + .withKeyValue("created", result.created()) + .build(); + } + + private static Generation buildGeneration(Choice choice, Map metadata) { + List toolCalls = choice.message().toolCalls() == null ? List.of() + : choice.message() + .toolCalls() + .stream() + .map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function", + toolCall.function().name(), toolCall.function().arguments())) + .toList(); + + var assistantMessage = new AssistantMessage(choice.message().content(), metadata, toolCalls); + String finishReason = (choice.finishReason() != null ? choice.finishReason().name() : ""); + var generationMetadata = ChatGenerationMetadata.from(finishReason, null); + return new Generation(assistantMessage, generationMetadata); + } + /** * Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null. * @param chunk the ChatCompletionChunk to convert @@ -235,49 +314,73 @@ public class MiniMaxChatModel extends */ ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { - Set functionsForThisRequest = new HashSet<>(); + List chatCompletionMessages = prompt.getInstructions().stream().map(message -> { + if (message.getMessageType() == MessageType.USER || message.getMessageType() == MessageType.SYSTEM) { + Object content = message.getContent(); + return List.of(new ChatCompletionMessage(content, + ChatCompletionMessage.Role.valueOf(message.getMessageType().name()))); + } + else if (message.getMessageType() == MessageType.ASSISTANT) { + var assistantMessage = (AssistantMessage) message; + List toolCalls = null; + if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) { + toolCalls = assistantMessage.getToolCalls().stream().map(toolCall -> { + var function = new ChatCompletionFunction(toolCall.name(), toolCall.arguments()); + return new ToolCall(toolCall.id(), toolCall.type(), function); + }).toList(); + } + return List.of(new ChatCompletionMessage(assistantMessage.getContent(), + ChatCompletionMessage.Role.ASSISTANT, null, null, toolCalls)); + } + else if (message.getMessageType() == MessageType.TOOL) { + ToolResponseMessage toolMessage = (ToolResponseMessage) message; - List chatCompletionMessages = prompt.getInstructions() - .stream() - .map(m -> new MiniMaxApi.ChatCompletionMessage(m.getContent(), - MiniMaxApi.ChatCompletionMessage.Role.valueOf(m.getMessageType().name()))) - .toList(); + toolMessage.getResponses().forEach(response -> { + Assert.isTrue(response.id() != null, "ToolResponseMessage must have an id"); + Assert.isTrue(response.name() != null, "ToolResponseMessage must have a name"); + }); + + return toolMessage.getResponses() + .stream() + .map(tr -> new ChatCompletionMessage(tr.responseData(), ChatCompletionMessage.Role.TOOL, tr.name(), + tr.id(), null)) + .toList(); + } + else { + throw new IllegalArgumentException("Unsupported message type: " + message.getMessageType()); + } + }).flatMap(List::stream).toList(); ChatCompletionRequest request = new ChatCompletionRequest(chatCompletionMessages, stream); + Set enabledToolsToUse = new HashSet<>(); + if (prompt.getOptions() != null) { MiniMaxChatOptions updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class, MiniMaxChatOptions.class); - Set promptEnabledFunctions = this.handleFunctionCallbackConfigurations(updatedRuntimeOptions, - IS_RUNTIME_CALL); - functionsForThisRequest.addAll(promptEnabledFunctions); + enabledToolsToUse.addAll(this.runtimeFunctionCallbackConfigurations(updatedRuntimeOptions)); request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, ChatCompletionRequest.class); } - if (this.defaultOptions != null) { - - Set defaultEnabledFunctions = this.handleFunctionCallbackConfigurations(this.defaultOptions, - !IS_RUNTIME_CALL); - - functionsForThisRequest.addAll(defaultEnabledFunctions); - - request = ModelOptionsUtils.merge(request, this.defaultOptions, ChatCompletionRequest.class); + if (!CollectionUtils.isEmpty(this.defaultOptions.getFunctions())) { + enabledToolsToUse.addAll(this.defaultOptions.getFunctions()); } - // Add the enabled functions definitions to the request's tools parameter. - if (!CollectionUtils.isEmpty(functionsForThisRequest)) { + request = ModelOptionsUtils.merge(request, this.defaultOptions, ChatCompletionRequest.class); + + if (!CollectionUtils.isEmpty(enabledToolsToUse)) { request = ModelOptionsUtils.merge( - MiniMaxChatOptions.builder().withTools(this.getFunctionTools(functionsForThisRequest)).build(), - request, ChatCompletionRequest.class); + MiniMaxChatOptions.builder().withTools(this.getFunctionTools(enabledToolsToUse)).build(), request, + ChatCompletionRequest.class); } return request; } - private List getFunctionTools(Set functionNames) { + private List getFunctionTools(Set functionNames) { return this.resolveFunctionCallbacks(functionNames).stream().map(functionCallback -> { var function = new FunctionTool.Function(functionCallback.getDescription(), functionCallback.getName(), functionCallback.getInputTypeSchema()); @@ -285,80 +388,4 @@ public class MiniMaxChatModel extends }).toList(); } - @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, Role.TOOL, functionName, toolCall.id(), null)); - } - - // Recursively call chatCompletionWithTools until the model doesn't call a - // functions anymore. - ChatCompletionRequest newRequest = new ChatCompletionRequest(conversationHistory, false); - 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.miniMaxApi.chatCompletionEntity(request); - } - - @Override - protected Flux> doChatCompletionStream(ChatCompletionRequest request) { - return this.miniMaxApi.chatCompletionStream(request) - .map(this::chunkToChatCompletion) - .map(Optional::ofNullable) - .map(ResponseEntity::of); - } - - @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; - } - - var choice = choices.get(0); - var message = choice.message(); - return message != null && !CollectionUtils.isEmpty(choice.message().toolCalls()) - && choice.finishReason() == ChatCompletionFinishReason.TOOL_CALLS; - } - - @Override - public ChatOptions getDefaultOptions() { - return MiniMaxChatOptions.fromOptions(this.defaultOptions); - } - } diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java index f1bf80773..9626004e3 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java @@ -15,11 +15,6 @@ */ package org.springframework.ai.minimax; -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.JsonInclude.Include; @@ -31,6 +26,11 @@ import org.springframework.ai.model.function.FunctionCallingOptions; import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.util.Assert; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + /** * MiniMaxChatOptions represents the options for performing chat completion using the * MiniMax API. It provides methods to set and retrieve various options like model, diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java index eb4923464..981e3335b 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java @@ -714,7 +714,7 @@ public class MiniMaxApi { .takeUntil(SSE_DONE_PREDICATE) .filter(SSE_DONE_PREDICATE.negate()) .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) - .map(chunk -> { + .map(chunk -> { if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { isInsideTool.set(true); } @@ -730,7 +730,7 @@ public class MiniMaxApi { .concatMapIterable(window -> { Mono monoChunk = window.reduce( new ChatCompletionChunk(null, null, null, null, null, null), - this.chunkMerger::merge); + (previous, current) -> this.chunkMerger.merge(previous, current)); return List.of(monoChunk); }) .flatMap(mono -> mono); diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxStreamFunctionCallingHelper.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxStreamFunctionCallingHelper.java index ceb92d03d..06454a348 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxStreamFunctionCallingHelper.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxStreamFunctionCallingHelper.java @@ -15,7 +15,16 @@ */ package org.springframework.ai.minimax.api; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionChunk; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionChunk.ChunkChoice; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionFinishReason; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.Role; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.minimax.api.MiniMaxApi.LogProbs; import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.List; @@ -29,14 +38,7 @@ import java.util.List; */ public class MiniMaxStreamFunctionCallingHelper { - /** - * Merge the previous and current ChatCompletionChunk into a single one. - * @param previous the previous ChatCompletionChunk - * @param current the current ChatCompletionChunk - * @return the merged ChatCompletionChunk - */ - public MiniMaxApi.ChatCompletionChunk merge(MiniMaxApi.ChatCompletionChunk previous, - MiniMaxApi.ChatCompletionChunk current) { + public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChunk current) { if (previous == null) { return current; @@ -49,47 +51,38 @@ public class MiniMaxStreamFunctionCallingHelper { : previous.systemFingerprint()); String object = (current.object() != null ? current.object() : previous.object()); - MiniMaxApi.ChatCompletionChunk.ChunkChoice previousChoice0 = (CollectionUtils.isEmpty(previous.choices()) ? null - : previous.choices().get(0)); - MiniMaxApi.ChatCompletionChunk.ChunkChoice currentChoice0 = (CollectionUtils.isEmpty(current.choices()) ? null - : current.choices().get(0)); + ChunkChoice previousChoice0 = (CollectionUtils.isEmpty(previous.choices()) ? null : previous.choices().get(0)); + ChunkChoice currentChoice0 = (CollectionUtils.isEmpty(current.choices()) ? null : current.choices().get(0)); - MiniMaxApi.ChatCompletionChunk.ChunkChoice choice = merge(previousChoice0, currentChoice0); - List chunkChoices = choice == null ? List.of() : List.of(choice); - return new MiniMaxApi.ChatCompletionChunk(id, chunkChoices, created, model, systemFingerprint, object); + ChunkChoice choice = merge(previousChoice0, currentChoice0); + List chunkChoices = choice == null ? List.of() : List.of(choice); + return new ChatCompletionChunk(id, chunkChoices, created, model, systemFingerprint, object); } - private MiniMaxApi.ChatCompletionChunk.ChunkChoice merge(MiniMaxApi.ChatCompletionChunk.ChunkChoice previous, - MiniMaxApi.ChatCompletionChunk.ChunkChoice current) { + private ChunkChoice merge(ChunkChoice previous, ChunkChoice current) { if (previous == null) { return current; } - MiniMaxApi.ChatCompletionFinishReason finishReason = (current.finishReason() != null ? current.finishReason() + ChatCompletionFinishReason finishReason = (current.finishReason() != null ? current.finishReason() : previous.finishReason()); Integer index = (current.index() != null ? current.index() : previous.index()); + LogProbs logprobs = (current.logprobs() != null ? current.logprobs() : previous.logprobs()); - MiniMaxApi.ChatCompletionMessage message = merge(previous.delta(), current.delta()); - - MiniMaxApi.LogProbs logprobs = (current.logprobs() != null ? current.logprobs() : previous.logprobs()); - return new MiniMaxApi.ChatCompletionChunk.ChunkChoice(finishReason, index, message, logprobs); + ChatCompletionMessage message = merge(previous.delta(), current.delta()); + return new ChunkChoice(finishReason, index, message, logprobs); } - private MiniMaxApi.ChatCompletionMessage merge(MiniMaxApi.ChatCompletionMessage previous, - MiniMaxApi.ChatCompletionMessage current) { + private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompletionMessage current) { String content = (current.content() != null ? current.content() - : (previous.content() != null) ? previous.content() : ""); - MiniMaxApi.ChatCompletionMessage.Role role = (current.role() != null ? current.role() : previous.role()); - role = (role != null ? role : MiniMaxApi.ChatCompletionMessage.Role.ASSISTANT); // default - // to - // ASSISTANT - // (if - // null + : "" + ((previous.content() != null) ? previous.content() : "")); + Role role = (current.role() != null ? current.role() : previous.role()); + role = (role != null ? role : Role.ASSISTANT); // default to ASSISTANT (if null String name = (current.name() != null ? current.name() : previous.name()); String toolCallId = (current.toolCallId() != null ? current.toolCallId() : previous.toolCallId()); - List toolCalls = new ArrayList<>(); - MiniMaxApi.ChatCompletionMessage.ToolCall lastPreviousTooCall = null; + List toolCalls = new ArrayList<>(); + ToolCall lastPreviousTooCall = null; if (previous.toolCalls() != null) { lastPreviousTooCall = previous.toolCalls().get(previous.toolCalls().size() - 1); if (previous.toolCalls().size() > 1) { @@ -101,43 +94,40 @@ public class MiniMaxStreamFunctionCallingHelper { throw new IllegalStateException("Currently only one tool call is supported per message!"); } var currentToolCall = current.toolCalls().iterator().next(); - if (currentToolCall.id() != null) { + if (currentToolCall.id() == null + || (lastPreviousTooCall != null && currentToolCall.id().equals(lastPreviousTooCall.id()))) { + toolCalls.add(merge(lastPreviousTooCall, currentToolCall)); + } + else { if (lastPreviousTooCall != null) { toolCalls.add(lastPreviousTooCall); } toolCalls.add(currentToolCall); } - else { - toolCalls.add(merge(lastPreviousTooCall, currentToolCall)); - } } else { if (lastPreviousTooCall != null) { toolCalls.add(lastPreviousTooCall); } } - return new MiniMaxApi.ChatCompletionMessage(content, role, name, toolCallId, toolCalls); + return new ChatCompletionMessage(content, role, name, toolCallId, toolCalls); } - private MiniMaxApi.ChatCompletionMessage.ToolCall merge(MiniMaxApi.ChatCompletionMessage.ToolCall previous, - MiniMaxApi.ChatCompletionMessage.ToolCall current) { + private ToolCall merge(ToolCall previous, ToolCall current) { if (previous == null) { return current; } String id = (current.id() != null ? current.id() : previous.id()); String type = (current.type() != null ? current.type() : previous.type()); - MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction function = merge(previous.function(), - current.function()); - return new MiniMaxApi.ChatCompletionMessage.ToolCall(id, type, function); + ChatCompletionFunction function = merge(previous.function(), current.function()); + return new ToolCall(id, type, function); } - private MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction merge( - MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction previous, - MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction current) { + private ChatCompletionFunction merge(ChatCompletionFunction previous, ChatCompletionFunction current) { if (previous == null) { return current; } - String name = (current.name() != null ? current.name() : previous.name()); + String name = (StringUtils.hasLength(current.name()) ? current.name() : previous.name()); StringBuilder arguments = new StringBuilder(); if (previous.arguments() != null) { arguments.append(previous.arguments()); @@ -145,14 +135,14 @@ public class MiniMaxStreamFunctionCallingHelper { if (current.arguments() != null) { arguments.append(current.arguments()); } - return new MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction(name, arguments.toString()); + return new ChatCompletionFunction(name, arguments.toString()); } /** * @param chatCompletion the ChatCompletionChunk to check * @return true if the ChatCompletionChunk is a streaming tool function call. */ - public boolean isStreamingToolFunctionCall(MiniMaxApi.ChatCompletionChunk chatCompletion) { + public boolean isStreamingToolFunctionCall(ChatCompletionChunk chatCompletion) { if (chatCompletion == null || CollectionUtils.isEmpty(chatCompletion.choices())) { return false; @@ -170,7 +160,7 @@ public class MiniMaxStreamFunctionCallingHelper { * @return true if the ChatCompletionChunk is a streaming tool function call and it is * the last one. */ - public boolean isStreamingToolFunctionCallFinish(MiniMaxApi.ChatCompletionChunk chatCompletion) { + public boolean isStreamingToolFunctionCallFinish(ChatCompletionChunk chatCompletion) { if (chatCompletion == null || CollectionUtils.isEmpty(chatCompletion.choices())) { return false; @@ -180,23 +170,7 @@ public class MiniMaxStreamFunctionCallingHelper { if (choice == null || choice.delta() == null) { return false; } - return choice.finishReason() == MiniMaxApi.ChatCompletionFinishReason.TOOL_CALLS; - } - - /** - * Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null. - * @param chunk the ChatCompletionChunk to convert - * @return the ChatCompletion - */ - public MiniMaxApi.ChatCompletion chunkToChatCompletion(MiniMaxApi.ChatCompletionChunk chunk) { - List choices = chunk.choices() - .stream() - .map(chunkChoice -> new MiniMaxApi.ChatCompletion.Choice(chunkChoice.finishReason(), chunkChoice.index(), - chunkChoice.delta(), chunkChoice.logprobs())) - .toList(); - - return new MiniMaxApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), - chunk.systemFingerprint(), "chat.completion", null, null); + return choice.finishReason() == ChatCompletionFinishReason.TOOL_CALLS; } } diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java index e4aecfcf5..77abfd0ce 100644 --- a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java @@ -54,7 +54,8 @@ public class MiniMaxApiToolFunctionCallIT { public void toolFunctionCall() { // Step 1: send the conversation and available functions to the model - var message = new ChatCompletionMessage("What's the weather like in San Francisco?", Role.USER); + var message = new ChatCompletionMessage( + "What's the weather like in San Francisco? Return the temperature in Celsius.", Role.USER); var functionTool = new MiniMaxApi.FunctionTool(Type.FUNCTION, new MiniMaxApi.FunctionTool.Function( "Get the weather in location. Return temperature in 30°F or 30°C format.", "getCurrentWeather", """ @@ -126,7 +127,8 @@ public class MiniMaxApiToolFunctionCallIT { 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", "30.0°F", "30°F"); + .containsAnyOf("30.0°C", "30°C", "30.0") + .containsAnyOf("°C", "Celsius"); } private static T fromJson(String json, Class targetClass) { diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java index d8d1f0a69..a4601d082 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java @@ -62,16 +62,8 @@ public class MiniMaxAutoConfiguration { var miniMaxApi = miniMaxApi(chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), chatProperties.getApiKey(), commonProperties.getApiKey(), restClientBuilder, responseErrorHandler); - MiniMaxChatModel chatModel = new MiniMaxChatModel(miniMaxApi, chatProperties.getOptions(), - functionCallbackContext, retryTemplate); - - if (!CollectionUtils.isEmpty(toolFunctionCallbacks)) { - Map toolFunctionCallbackMap = toolFunctionCallbacks.stream() - .collect(Collectors.toMap(FunctionCallback::getName, Function.identity(), (a, b) -> b)); - chatModel.getFunctionCallbackRegister().putAll(toolFunctionCallbackMap); - } - - return chatModel; + return new MiniMaxChatModel(miniMaxApi, chatProperties.getOptions(), functionCallbackContext, + toolFunctionCallbacks, retryTemplate); } @Bean diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java index 335dc6aaa..6d97b82de 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java @@ -20,10 +20,10 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.minimax.MiniMaxChatModel; import org.springframework.ai.minimax.MiniMaxChatOptions; @@ -53,11 +53,12 @@ public class FunctionCallbackInPromptIT { @Test void functionCallTest() { - contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); - UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); var promptOptions = MiniMaxChatOptions.builder() .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) @@ -78,11 +79,12 @@ public class FunctionCallbackInPromptIT { @Test void streamingFunctionCallTest() { - contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); - UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); var promptOptions = MiniMaxChatOptions.builder() .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java index 8af0a7960..ceb00d476 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java @@ -20,10 +20,10 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.minimax.MiniMaxChatModel; import org.springframework.ai.minimax.MiniMaxChatOptions; @@ -57,14 +57,16 @@ class FunctionCallbackWithPlainFunctionBeanIT { RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) .withUserConfiguration(Config.class); + // FIXME: multiple function calls may stop prematurely due to model performance @Test void functionCallTest() { - contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); // Test weatherFunction - UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); ChatResponse response = chatModel.call(new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().withFunction("weatherFunction").build())); @@ -86,12 +88,13 @@ class FunctionCallbackWithPlainFunctionBeanIT { @Test void functionCallWithPortableFunctionCallingOptions() { - contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); // Test weatherFunction - UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); PortableFunctionCallingOptions functionOptions = FunctionCallingOptions.builder() .withFunction("weatherFunction") @@ -103,14 +106,16 @@ class FunctionCallbackWithPlainFunctionBeanIT { }); } + // FIXME: multiple function calls may stop prematurely due to model performance @Test void streamFunctionCallTest() { - contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); // Test weatherFunction - UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); Flux response = chatModel.stream(new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().withFunction("weatherFunction").build())); diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWrapperIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWrapperIT.java index 98a077d78..612622d69 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWrapperIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWrapperIT.java @@ -20,10 +20,10 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.minimax.MiniMaxChatModel; import org.springframework.ai.minimax.MiniMaxChatOptions; @@ -57,11 +57,12 @@ public class FunctionCallbackWrapperIT { @Test void functionCallTest() { - contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); - UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); ChatResponse response = chatModel.call( new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().withFunction("WeatherInfo").build())); @@ -75,11 +76,12 @@ public class FunctionCallbackWrapperIT { @Test void streamFunctionCallTest() { - contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6.5s-chat").run(context -> { MiniMaxChatModel chatModel = context.getBean(MiniMaxChatModel.class); - UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius."); Flux response = chatModel.stream( new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().withFunction("WeatherInfo").build())); diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java index eff89cb0d..507ed41ff 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java @@ -20,8 +20,8 @@ import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; -import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.minimax.MiniMaxChatModel; diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java index 29967264e..b7f792d3a 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java @@ -34,7 +34,7 @@ public class MockWeatherService implements Function