mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-09-13 00:14:59 +00:00
Refresh MiniMax models and protocol support (#313)
Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
This commit is contained in:
@@ -35,6 +35,12 @@
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-anthropic</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-ollama</artifactId>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
import dev.langchain4j.model.anthropic.AnthropicChatModel;
|
||||
import dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
@@ -10,13 +14,16 @@ import org.ruoyi.observability.MyChatModelListener;
|
||||
import org.ruoyi.service.chat.AbstractChatService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MiniMax服务调用
|
||||
* MiniMax chat service.
|
||||
* <p>
|
||||
* MiniMax提供OpenAI兼容的API接口,支持MiniMax-M2.7、MiniMax-M2.5等模型。
|
||||
* API地址:https://api.minimax.io/v1
|
||||
* Supports MiniMax-M3 and MiniMax-M2.7 through the OpenAI-compatible and
|
||||
* Anthropic-compatible APIs in both global and China regions.
|
||||
*
|
||||
* @author octopus
|
||||
* @date 2026/3/21
|
||||
@@ -25,14 +32,57 @@ import java.util.List;
|
||||
@Slf4j
|
||||
public class MinimaxServiceImpl implements AbstractChatService {
|
||||
|
||||
private static final String MINIMAX_M3 = "MiniMax-M3";
|
||||
private static final String ANTHROPIC_PATH_SUFFIX = "/anthropic";
|
||||
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(120);
|
||||
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
String baseUrl = normalizeBaseUrl(chatModelVo.getApiHost());
|
||||
boolean thinkingEnabled = Boolean.TRUE.equals(chatRequest.getEnableThinking());
|
||||
String thinkingType = thinkingType(chatModelVo.getModelName(), thinkingEnabled);
|
||||
|
||||
if (isAnthropicBaseUrl(baseUrl)) {
|
||||
return AnthropicStreamingChatModel.builder()
|
||||
.baseUrl(toAnthropicClientBaseUrl(baseUrl))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.thinkingType(thinkingType)
|
||||
.returnThinking(thinkingEnabled)
|
||||
.build();
|
||||
}
|
||||
|
||||
OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder builder = OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.returnThinking(chatRequest.getEnableThinking())
|
||||
.returnThinking(thinkingEnabled);
|
||||
if (thinkingType != null) {
|
||||
builder.customParameters(Map.of("thinking", Map.of("type", thinkingType)));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo chatModelVo) {
|
||||
String baseUrl = normalizeBaseUrl(chatModelVo.getApiHost());
|
||||
if (isAnthropicBaseUrl(baseUrl)) {
|
||||
return AnthropicChatModel.builder()
|
||||
.baseUrl(toAnthropicClientBaseUrl(baseUrl))
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.build();
|
||||
}
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -41,4 +91,40 @@ public class MinimaxServiceImpl implements AbstractChatService {
|
||||
return ChatModeType.MINIMAX.getCode();
|
||||
}
|
||||
|
||||
static String thinkingType(String modelName, boolean thinkingEnabled) {
|
||||
if (!MINIMAX_M3.equals(modelName)) {
|
||||
return null;
|
||||
}
|
||||
return thinkingEnabled ? "adaptive" : "disabled";
|
||||
}
|
||||
|
||||
private static String normalizeBaseUrl(String baseUrl) {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("MiniMax API Host must not be blank");
|
||||
}
|
||||
String normalized = baseUrl.trim();
|
||||
while (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = URI.create(normalized);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("MiniMax API Host must be a valid URL", exception);
|
||||
}
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
throw new IllegalArgumentException("MiniMax API Host must be an absolute URL");
|
||||
}
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
private static boolean isAnthropicBaseUrl(String baseUrl) {
|
||||
return URI.create(baseUrl).getPath().endsWith(ANTHROPIC_PATH_SUFFIX);
|
||||
}
|
||||
|
||||
private static String toAnthropicClientBaseUrl(String baseUrl) {
|
||||
// LangChain4j appends /messages, while MiniMax exposes /anthropic/v1/messages.
|
||||
return baseUrl + "/v1";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package org.ruoyi.integration;
|
||||
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.service.chat.impl.provider.MinimaxServiceImpl;
|
||||
@@ -26,45 +27,28 @@ class MinimaxIntegrationTest {
|
||||
apiKey = System.getenv("MINIMAX_API_KEY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildStreamingChatModel_withRealApiKey_M27() {
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"https://api.minimax.io/v1, MiniMax-M3, false",
|
||||
"https://api.minimax.io/v1, MiniMax-M2.7, true",
|
||||
"https://api.minimaxi.com/v1, MiniMax-M3, true",
|
||||
"https://api.minimaxi.com/v1, MiniMax-M2.7, true",
|
||||
"https://api.minimax.io/anthropic, MiniMax-M3, false",
|
||||
"https://api.minimax.io/anthropic, MiniMax-M2.7, true",
|
||||
"https://api.minimaxi.com/anthropic, MiniMax-M3, true",
|
||||
"https://api.minimaxi.com/anthropic, MiniMax-M2.7, true"
|
||||
})
|
||||
void buildStreamingChatModel_withConfiguredApiKey(
|
||||
String apiHost, String modelName, boolean enableThinking) {
|
||||
ChatModelVo modelVo = new ChatModelVo();
|
||||
modelVo.setApiHost("https://api.minimax.io/v1");
|
||||
modelVo.setApiHost(apiHost);
|
||||
modelVo.setApiKey(apiKey);
|
||||
modelVo.setModelName("MiniMax-M2.7");
|
||||
modelVo.setModelName(modelName);
|
||||
|
||||
ChatRequest request = new ChatRequest();
|
||||
request.setEnableThinking(false);
|
||||
request.setEnableThinking(enableThinking);
|
||||
|
||||
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request);
|
||||
assertNotNull(model, "Should create streaming model with real API key");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildStreamingChatModel_withRealApiKey_M25() {
|
||||
ChatModelVo modelVo = new ChatModelVo();
|
||||
modelVo.setApiHost("https://api.minimax.io/v1");
|
||||
modelVo.setApiKey(apiKey);
|
||||
modelVo.setModelName("MiniMax-M2.5");
|
||||
|
||||
ChatRequest request = new ChatRequest();
|
||||
request.setEnableThinking(false);
|
||||
|
||||
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request);
|
||||
assertNotNull(model, "Should create streaming model with M2.5");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildStreamingChatModel_withRealApiKey_M25Highspeed() {
|
||||
ChatModelVo modelVo = new ChatModelVo();
|
||||
modelVo.setApiHost("https://api.minimax.io/v1");
|
||||
modelVo.setApiKey(apiKey);
|
||||
modelVo.setModelName("MiniMax-M2.5-highspeed");
|
||||
|
||||
ChatRequest request = new ChatRequest();
|
||||
request.setEnableThinking(false);
|
||||
|
||||
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request);
|
||||
assertNotNull(model, "Should create streaming model with M2.5-highspeed");
|
||||
assertNotNull(model, "Should create streaming model for " + modelName + " at " + apiHost);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import dev.langchain4j.model.anthropic.AnthropicChatModel;
|
||||
import dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatRequestParameters;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.enums.ChatModeType;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
@@ -14,6 +30,8 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
class MinimaxServiceImplTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private MinimaxServiceImpl minimaxService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -27,50 +45,126 @@ class MinimaxServiceImplTest {
|
||||
assertEquals(ChatModeType.MINIMAX.getCode(), minimaxService.getProviderName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildStreamingChatModel_returnsNonNull() {
|
||||
ChatModelVo modelVo = new ChatModelVo();
|
||||
modelVo.setApiHost("https://api.minimax.io/v1");
|
||||
modelVo.setApiKey("test-api-key");
|
||||
modelVo.setModelName("MiniMax-M2.7");
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"https://api.minimax.io/v1, MiniMax-M3, false, openai",
|
||||
"https://api.minimax.io/v1, MiniMax-M2.7, true, openai",
|
||||
"https://api.minimaxi.com/v1, MiniMax-M3, true, openai",
|
||||
"https://api.minimaxi.com/v1, MiniMax-M2.7, true, openai",
|
||||
"https://api.minimax.io/anthropic, MiniMax-M3, false, anthropic",
|
||||
"https://api.minimax.io/anthropic, MiniMax-M2.7, true, anthropic",
|
||||
"https://api.minimaxi.com/anthropic, MiniMax-M3, true, anthropic",
|
||||
"https://api.minimaxi.com/anthropic, MiniMax-M2.7, true, anthropic"
|
||||
})
|
||||
void buildStreamingChatModel_supportsCurrentModelsRegionsAndProtocols(
|
||||
String apiHost, String modelName, boolean enableThinking, String protocol) {
|
||||
ChatRequest request = new ChatRequest();
|
||||
request.setEnableThinking(false);
|
||||
request.setEnableThinking(enableThinking);
|
||||
|
||||
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request);
|
||||
assertNotNull(model);
|
||||
StreamingChatModel model = minimaxService.buildStreamingChatModel(
|
||||
modelVo(apiHost, modelName), request);
|
||||
|
||||
if ("anthropic".equals(protocol)) {
|
||||
assertInstanceOf(AnthropicStreamingChatModel.class, model);
|
||||
} else {
|
||||
assertInstanceOf(OpenAiStreamingChatModel.class, model);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"https://api.minimax.io/v1, openai",
|
||||
"https://api.minimaxi.com/v1, openai",
|
||||
"https://api.minimax.io/anthropic, anthropic",
|
||||
"https://api.minimaxi.com/anthropic, anthropic"
|
||||
})
|
||||
void buildChatModel_supportsRegionalProtocolEndpoints(String apiHost, String protocol) {
|
||||
ChatModel model = minimaxService.buildChatModel(modelVo(apiHost, "MiniMax-M3"));
|
||||
|
||||
if ("anthropic".equals(protocol)) {
|
||||
assertInstanceOf(AnthropicChatModel.class, model);
|
||||
} else {
|
||||
assertInstanceOf(OpenAiChatModel.class, model);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"true, adaptive", "false, disabled"})
|
||||
void buildStreamingChatModel_mapsM3ThinkingSetting(boolean enabled, String expectedType) {
|
||||
ChatRequest request = new ChatRequest();
|
||||
request.setEnableThinking(enabled);
|
||||
|
||||
OpenAiStreamingChatModel model = assertInstanceOf(OpenAiStreamingChatModel.class,
|
||||
minimaxService.buildStreamingChatModel(
|
||||
modelVo("https://api.minimax.io/v1", "MiniMax-M3"), request));
|
||||
OpenAiChatRequestParameters parameters = assertInstanceOf(
|
||||
OpenAiChatRequestParameters.class, model.defaultRequestParameters());
|
||||
Map<?, ?> thinking = assertInstanceOf(
|
||||
Map.class, parameters.customParameters().get("thinking"));
|
||||
|
||||
assertEquals(expectedType, thinking.get("type"));
|
||||
assertEquals(expectedType, MinimaxServiceImpl.thinkingType("MiniMax-M3", enabled));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildStreamingChatModel_withThinkingEnabled() {
|
||||
ChatModelVo modelVo = new ChatModelVo();
|
||||
modelVo.setApiHost("https://api.minimax.io/v1");
|
||||
modelVo.setApiKey("test-api-key");
|
||||
modelVo.setModelName("MiniMax-M2.5");
|
||||
|
||||
ChatRequest request = new ChatRequest();
|
||||
request.setEnableThinking(true);
|
||||
|
||||
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request);
|
||||
assertNotNull(model);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildStreamingChatModel_withHighspeedModel() {
|
||||
ChatModelVo modelVo = new ChatModelVo();
|
||||
modelVo.setApiHost("https://api.minimax.io/v1");
|
||||
modelVo.setApiKey("test-api-key");
|
||||
modelVo.setModelName("MiniMax-M2.5-highspeed");
|
||||
|
||||
void buildStreamingChatModel_leavesM27ThinkingAlwaysOn() {
|
||||
ChatRequest request = new ChatRequest();
|
||||
request.setEnableThinking(false);
|
||||
|
||||
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request);
|
||||
assertNotNull(model);
|
||||
OpenAiStreamingChatModel model = assertInstanceOf(OpenAiStreamingChatModel.class,
|
||||
minimaxService.buildStreamingChatModel(
|
||||
modelVo("https://api.minimax.io/v1", "MiniMax-M2.7"), request));
|
||||
OpenAiChatRequestParameters parameters = assertInstanceOf(
|
||||
OpenAiChatRequestParameters.class, model.defaultRequestParameters());
|
||||
|
||||
assertNull(MinimaxServiceImpl.thinkingType("MiniMax-M2.7", false));
|
||||
assertTrue(parameters.customParameters() == null || parameters.customParameters().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildChatModel_appendsAnthropicMessagesPathInternally() throws Exception {
|
||||
AtomicReference<String> requestPath = new AtomicReference<>();
|
||||
AtomicReference<String> requestBody = new AtomicReference<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
requestPath.set(exchange.getRequestURI().getPath());
|
||||
requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
byte[] response = ("{\"id\":\"msg_test\",\"type\":\"message\","
|
||||
+ "\"role\":\"assistant\",\"model\":\"MiniMax-M3\","
|
||||
+ "\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],"
|
||||
+ "\"stop_reason\":\"end_turn\",\"stop_sequence\":null,"
|
||||
+ "\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, response.length);
|
||||
exchange.getResponseBody().write(response);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
|
||||
try {
|
||||
String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/anthropic";
|
||||
ChatModel model = minimaxService.buildChatModel(modelVo(baseUrl, "MiniMax-M3"));
|
||||
|
||||
assertEquals("ok", model.chat("Hello"));
|
||||
assertEquals("/anthropic/v1/messages", requestPath.get());
|
||||
JsonNode body = OBJECT_MAPPER.readTree(requestBody.get());
|
||||
assertEquals("MiniMax-M3", body.path("model").asText());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void implementsAbstractChatService() {
|
||||
assertInstanceOf(org.ruoyi.service.chat.AbstractChatService.class, minimaxService);
|
||||
}
|
||||
|
||||
private static ChatModelVo modelVo(String apiHost, String modelName) {
|
||||
ChatModelVo modelVo = new ChatModelVo();
|
||||
modelVo.setApiHost(apiHost);
|
||||
modelVo.setApiKey("test-api-key");
|
||||
modelVo.setModelName(modelName);
|
||||
return modelVo;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user