4 Commits

Author SHA1 Message Date
ageerle@163.com
06d110ed16 @docs: 修正 snailjob 注释里失效的 ry_job.sql 引用
实际建表脚本在 docs/script/sql/ruoyi-ai-v3_mysql8.sql(sj_ 前缀表),仓库中并无 ry_job.sql。
关联 issue #311。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 18:25:55 +08:00
ageerle@163.com
dfcadda2bb @style: 修正 QdrantVectorStoreStrategy 缩进为空格
createSchema 调用与文件其余代码保持 4 空格缩进统一,避免 checkstyle/spotless 报错。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 18:00:07 +08:00
Jack Jiang
3c9546ded9 fix: 修复 Qdrant 初始化创建集合问题 (#298) 2026-07-15 17:58:12 +08:00
Octopus
a3d82e092e Refresh MiniMax models and protocol support (#313)
Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
2026-07-15 17:43:51 +08:00
9 changed files with 277 additions and 78 deletions

View File

@@ -176,6 +176,34 @@ docker-compose -f docker-compose-all.yaml logs -f [service-name]
docker-compose -f docker-compose-all.yaml restart [service-name] docker-compose -f docker-compose-all.yaml restart [service-name]
``` ```
### MiniMax Configuration
The built-in MiniMax provider accepts one API Host value and selects the matching protocol adapter. Use a Base URL from this table:
| Region | OpenAI-compatible Base URL | Anthropic-compatible Base URL |
| --- | --- | --- |
| Global | `https://api.minimax.io/v1` | `https://api.minimax.io/anthropic` |
| China | `https://api.minimaxi.com/v1` | `https://api.minimaxi.com/anthropic` |
For Anthropic-compatible requests, configure the Base URL ending in `/anthropic`. Do not append `/v1` or `/v1/messages`; the provider adapter derives the request path internally.
| Model ID | Total context | Input modalities | Thinking |
| --- | ---: | --- | --- |
| `MiniMax-M3` | 1,000,000 tokens | Text, image, video | Adaptive or disabled |
| `MiniMax-M2.7` | 204,800 tokens | Text | Always on |
Current pay-as-you-go prices are in USD per million tokens:
| Model | Service tier and input range | Input | Output | Cache read | Cache write |
| --- | --- | ---: | ---: | ---: | ---: |
| `MiniMax-M3` | Standard, up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | Not listed |
| `MiniMax-M3` | Standard, over 512,000 input tokens | $0.60 | $2.40 | $0.12 | Not listed |
| `MiniMax-M3` | Priority, up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | Not listed |
| `MiniMax-M3` | Priority, over 512,000 input tokens | $0.90 | $3.60 | $0.18 | Not listed |
| `MiniMax-M2.7` | Standard | $0.30 | $1.20 | $0.06 | $0.375 |
See the [official API overview](https://platform.minimax.io/docs/api-reference/api-overview) and [pay-as-you-go pricing](https://platform.minimax.io/docs/guides/pricing-paygo) for current details.
## 📚 Documentation ## 📚 Documentation
Want to learn more about installation, deployment, configuration, and secondary development? Want to learn more about installation, deployment, configuration, and secondary development?

View File

@@ -153,7 +153,7 @@ INSERT INTO `chat_provider` VALUES (12, '智谱AI', 'zhipu', 'https://example-12
INSERT INTO `chat_provider` VALUES (13, '小米MIMO', 'xiaomi', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/18dd39365ce244e3ae5e030da036760e.png', '小米官方API', 'https://api.xiaomimimo.com/v1', '0', 3, 103, '2026-04-19 12:48:24', '1', '1', '2026-04-19 13:14:22', '小米官方API', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (13, '小米MIMO', 'xiaomi', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/18dd39365ce244e3ae5e030da036760e.png', '小米官方API', 'https://api.xiaomimimo.com/v1', '0', 3, 103, '2026-04-19 12:48:24', '1', '1', '2026-04-19 13:14:22', '小米官方API', NULL, '0', NULL, 0);
INSERT INTO `chat_provider` VALUES (14, '阿里云百炼', 'qianwen', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (14, '阿里云百炼', 'qianwen', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/de2aa7e649de44f3ba5c6380ac6acd04.png', '阿里云百炼大模型服务', 'https://dashscope.aliyuncs.com', '0', 2, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:49:13', '阿里云厂商', NULL, '0', NULL, 0);
INSERT INTO `chat_provider` VALUES (15, 'Atlas Cloud', 'atlas', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/atlascloud.png', '全模态AI推理平台', 'https://api.atlascloud.ai/v1', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'Atlas Cloud AI平台', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (15, 'Atlas Cloud', 'atlas', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/atlascloud.png', '全模态AI推理平台', 'https://api.atlascloud.ai/v1', '0', 5, 103, '2025-12-15 23:13:42', '1', '1', '2026-02-25 20:49:01', 'Atlas Cloud AI平台', NULL, '0', NULL, 0);
INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax大模型服务支持M2.7、M2.5等模型', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax厂商', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (16, 'MiniMax', 'minimax', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/fdc712e90e0e4d78b05862ad230884e5.png', 'MiniMax model service supporting MiniMax-M3 and MiniMax-M2.7', 'https://api.minimax.io/v1', '0', 6, 103, '2026-04-19 12:50:12', '1', '1', '2026-04-19 13:14:59', 'MiniMax provider', NULL, '0', NULL, 0);
INSERT INTO `chat_provider` VALUES (17, 'ollama', 'ollama', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 7, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (17, 'ollama', 'ollama', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/02/25/afecabebc8014d80b0f06b4796a74c5d.png', 'ollama大模型', 'http://127.0.0.1:11434', '0', 7, 103, '2025-12-14 21:48:11', '1', '1', '2026-02-25 20:48:48', 'ollama厂商', NULL, '0', NULL, 0);
INSERT INTO `chat_provider` VALUES (18, '自定义厂商', 'custom_api', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/c1a8e122510f4e2f90deb36958af710b.png', 'OPENAI兼容格式', '自定义', '0', 8, 103, '2026-04-19 12:35:57', '1', '1', '2026-04-19 13:17:20', 'OPENAI兼容格式', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (18, '自定义厂商', 'custom_api', 'https://example-1234567890.cos.ap-guangzhou.myqcloud.com/2026/04/19/c1a8e122510f4e2f90deb36958af710b.png', 'OPENAI兼容格式', '自定义', '0', 8, 103, '2026-04-19 12:35:57', '1', '1', '2026-04-19 13:17:20', 'OPENAI兼容格式', NULL, '0', NULL, 0);
INSERT INTO `chat_provider` VALUES (2070700000000000001, 'Dify', 'dify', NULL, 'Dify 应用 API 服务', 'https://api.dify.ai/v1', '0', 9, 103, '2026-07-14 11:03:44', '1', '1', '2026-07-14 11:03:44', 'Dify 应用 API 服务商', NULL, '0', NULL, 0); INSERT INTO `chat_provider` VALUES (2070700000000000001, 'Dify', 'dify', NULL, 'Dify 应用 API 服务', 'https://api.dify.ai/v1', '0', 9, 103, '2026-07-14 11:03:44', '1', '1', '2026-07-14 11:03:44', 'Dify 应用 API 服务商', NULL, '0', NULL, 0);

View File

@@ -27,12 +27,12 @@ snail-job:
enabled: false enabled: false
# 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务 # 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务
group: "ruoyi_group" group: "ruoyi_group"
# SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config` 表 # SnailJob 接入验证令牌 详见 docs/script/sql/ruoyi-ai-v3_mysql8.sql `sj_group_config` 表
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT" token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server: server:
host: 127.0.0.1 host: 127.0.0.1
port: 17888 port: 17888
# 命名空间UUID 详见 script/sql/ry_job.sql `sj_namespace`表`unique_id`字段 # 命名空间UUID 详见 docs/script/sql/ruoyi-ai-v3_mysql8.sql `sj_namespace`表`unique_id`字段
namespace: ${spring.profiles.active} namespace: ${spring.profiles.active}
# 随主应用端口漂移 # 随主应用端口漂移
port: 2${server.port} port: 2${server.port}

View File

@@ -28,12 +28,12 @@ snail-job:
enabled: false enabled: false
# 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务 # 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务
group: "ruoyi_group" group: "ruoyi_group"
# SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config` 表 # SnailJob 接入验证令牌 详见 docs/script/sql/ruoyi-ai-v3_mysql8.sql `sj_group_config` 表
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT" token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server: server:
host: 127.0.0.1 host: 127.0.0.1
port: 17888 port: 17888
# 命名空间UUID 详见 script/sql/ry_job.sql `sj_namespace`表`unique_id`字段 # 命名空间UUID 详见 docs/script/sql/ruoyi-ai-v3_mysql8.sql `sj_namespace`表`unique_id`字段
namespace: ${spring.profiles.active} namespace: ${spring.profiles.active}
# 随主应用端口漂移 # 随主应用端口漂移
port: 2${server.port} port: 2${server.port}

View File

@@ -35,6 +35,12 @@
<version>${langchain4j.version}</version> <version>${langchain4j.version}</version>
</dependency> </dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-anthropic</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency> <dependency>
<groupId>dev.langchain4j</groupId> <groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId> <artifactId>langchain4j-ollama</artifactId>

View File

@@ -1,6 +1,10 @@
package org.ruoyi.service.chat.impl.provider; 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.chat.StreamingChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.chat.domain.dto.request.ChatRequest; 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.ruoyi.service.chat.AbstractChatService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.net.URI;
import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* MiniMax服务调用 * MiniMax chat service.
* <p> * <p>
* MiniMax提供OpenAI兼容的API接口支持MiniMax-M2.7、MiniMax-M2.5等模型。 * Supports MiniMax-M3 and MiniMax-M2.7 through the OpenAI-compatible and
* API地址https://api.minimax.io/v1 * Anthropic-compatible APIs in both global and China regions.
* *
* @author octopus * @author octopus
* @date 2026/3/21 * @date 2026/3/21
@@ -25,14 +32,57 @@ import java.util.List;
@Slf4j @Slf4j
public class MinimaxServiceImpl implements AbstractChatService { 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 @Override
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
return OpenAiStreamingChatModel.builder() String baseUrl = normalizeBaseUrl(chatModelVo.getApiHost());
.baseUrl(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()) .apiKey(chatModelVo.getApiKey())
.modelName(chatModelVo.getModelName()) .modelName(chatModelVo.getModelName())
.timeout(DEFAULT_TIMEOUT)
.listeners(List.of(new MyChatModelListener())) .listeners(List.of(new MyChatModelListener()))
.returnThinking(chatRequest.getEnableThinking()) .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(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(); .build();
} }
@@ -41,4 +91,40 @@ public class MinimaxServiceImpl implements AbstractChatService {
return ChatModeType.MINIMAX.getCode(); 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";
}
} }

View File

@@ -116,6 +116,7 @@ public class QdrantVectorStoreStrategy extends AbstractVectorStoreStrategy {
@Override @Override
public void storeEmbeddings(StoreEmbeddingBo storeEmbeddingBo) { public void storeEmbeddings(StoreEmbeddingBo storeEmbeddingBo) {
createSchema(storeEmbeddingBo.getKid(), storeEmbeddingBo.getEmbeddingModelName());
EmbeddingModel embeddingModel = getEmbeddingModel(storeEmbeddingBo.getEmbeddingModelName()); EmbeddingModel embeddingModel = getEmbeddingModel(storeEmbeddingBo.getEmbeddingModelName());
List<String> chunkList = storeEmbeddingBo.getChunkList(); List<String> chunkList = storeEmbeddingBo.getChunkList();
List<String> fidList = storeEmbeddingBo.getFids(); List<String> fidList = storeEmbeddingBo.getFids();

View File

@@ -2,8 +2,9 @@ package org.ruoyi.integration;
import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.chat.StreamingChatModel;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; 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.dto.request.ChatRequest;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.service.chat.impl.provider.MinimaxServiceImpl; import org.ruoyi.service.chat.impl.provider.MinimaxServiceImpl;
@@ -26,45 +27,28 @@ class MinimaxIntegrationTest {
apiKey = System.getenv("MINIMAX_API_KEY"); apiKey = System.getenv("MINIMAX_API_KEY");
} }
@Test @ParameterizedTest
void buildStreamingChatModel_withRealApiKey_M27() { @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(); ChatModelVo modelVo = new ChatModelVo();
modelVo.setApiHost("https://api.minimax.io/v1"); modelVo.setApiHost(apiHost);
modelVo.setApiKey(apiKey); modelVo.setApiKey(apiKey);
modelVo.setModelName("MiniMax-M2.7"); modelVo.setModelName(modelName);
ChatRequest request = new ChatRequest(); ChatRequest request = new ChatRequest();
request.setEnableThinking(false); request.setEnableThinking(enableThinking);
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request);
assertNotNull(model, "Should create streaming model with real API key"); assertNotNull(model, "Should create streaming model for " + modelName + " at " + apiHost);
}
@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");
} }
} }

View File

@@ -1,12 +1,28 @@
package org.ruoyi.service.chat.impl.provider; 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.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.BeforeEach;
import org.junit.jupiter.api.Test; 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.dto.request.ChatRequest;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.enums.ChatModeType; 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.*; import static org.junit.jupiter.api.Assertions.*;
/** /**
@@ -14,6 +30,8 @@ import static org.junit.jupiter.api.Assertions.*;
*/ */
class MinimaxServiceImplTest { class MinimaxServiceImplTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private MinimaxServiceImpl minimaxService; private MinimaxServiceImpl minimaxService;
@BeforeEach @BeforeEach
@@ -27,50 +45,126 @@ class MinimaxServiceImplTest {
assertEquals(ChatModeType.MINIMAX.getCode(), minimaxService.getProviderName()); assertEquals(ChatModeType.MINIMAX.getCode(), minimaxService.getProviderName());
} }
@Test @ParameterizedTest
void buildStreamingChatModel_returnsNonNull() { @CsvSource({
ChatModelVo modelVo = new ChatModelVo(); "https://api.minimax.io/v1, MiniMax-M3, false, openai",
modelVo.setApiHost("https://api.minimax.io/v1"); "https://api.minimax.io/v1, MiniMax-M2.7, true, openai",
modelVo.setApiKey("test-api-key"); "https://api.minimaxi.com/v1, MiniMax-M3, true, openai",
modelVo.setModelName("MiniMax-M2.7"); "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(); ChatRequest request = new ChatRequest();
request.setEnableThinking(false); request.setEnableThinking(enableThinking);
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); StreamingChatModel model = minimaxService.buildStreamingChatModel(
assertNotNull(model); 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 @Test
void buildStreamingChatModel_withThinkingEnabled() { void buildStreamingChatModel_leavesM27ThinkingAlwaysOn() {
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");
ChatRequest request = new ChatRequest(); ChatRequest request = new ChatRequest();
request.setEnableThinking(false); request.setEnableThinking(false);
StreamingChatModel model = minimaxService.buildStreamingChatModel(modelVo, request); OpenAiStreamingChatModel model = assertInstanceOf(OpenAiStreamingChatModel.class,
assertNotNull(model); 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 @Test
void implementsAbstractChatService() { void implementsAbstractChatService() {
assertInstanceOf(org.ruoyi.service.chat.AbstractChatService.class, minimaxService); 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;
}
} }