Skip to content

Commit be24185

Browse files
authored
feat: update MiniMax model catalog with dynamic discovery and endpoint normalization (#387)
MiniMax dual-region endpoints normalized to /v1. Dynamic model catalog with fallback to 8 official model IDs (MiniMax-M3, M2.7/-highspeed, M2.5/-highspeed, M2.1/-highspeed, M2) on empty response or BadGateway. RefreshAllChannel skips soft-delete of missing models only when Provider == MiniMax.
1 parent aefc900 commit be24185

6 files changed

Lines changed: 175 additions & 19 deletions

File tree

Docs/Bot_Commands_User_Guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@
199199
* **`刷新所有渠道` 指令的交互流程示例:**
200200
1. 管理员发送: `刷新所有渠道`
201201
2. 机器人回复: `已添加{n}个模型` (刷新并返回添加的模型数量)
202+
* **MiniMax 模型目录说明**: `MiniMax` 渠道使用 OpenAI-compatible `GET /v1/models` 动态发现账户可见模型;中国区网关为 `https://api.minimaxi.com`,国际区为 `https://api.minimax.io`,系统会自动补齐且不会重复追加 `/v1`。目录请求失败或返回空集合时,回退到官方文本模型快照 `MiniMax-M3``MiniMax-M2.7` / `MiniMax-M2.7-highspeed``MiniMax-M2.5` / `MiniMax-M2.5-highspeed``MiniMax-M2.1` / `MiniMax-M2.1-highspeed``MiniMax-M2`;刷新不会删除手工添加的旧版或账户专属模型 ID。官方依据(访问于 2026-08-14):[中国区模型列表](https://platform.minimaxi.com/docs/api-reference/models/openai/list-models)[国际区模型列表](https://platform.minimax.io/docs/api-reference/models/openai/list-models)[中国区 OpenAI API](https://platform.minimaxi.com/docs/api-reference/text-openai-api)[国际区 OpenAI API](https://platform.minimax.io/docs/api-reference/text-openai-api)
202203
* **`设置重试次数` 指令的交互流程示例:**
203204
1. 管理员发送: `设置重试次数`
204205
2. 机器人回复: `请输入最大重试次数(默认100):`

TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ public void GetLLMService_Gemini_ReturnsGeminiService() {
9090
Assert.IsAssignableFrom<ILLMService>(service);
9191
}
9292

93+
[Fact]
94+
public void GetLLMService_MiniMax_ReturnsOpenAIService() {
95+
var service = _factory.GetLLMService(LLMProvider.MiniMax);
96+
97+
Assert.Same(_openAIServiceMock.Object, service);
98+
}
99+
93100
[Fact]
94101
public void GetLLMService_None_ThrowsKeyNotFound() {
95102
Assert.Throws<KeyNotFoundException>(() => _factory.GetLLMService(LLMProvider.None));
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
using System;
2+
using System.Linq;
3+
using System.Net;
4+
using System.Net.Http;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.Extensions.Logging;
8+
using Moq;
9+
using TelegramSearchBot.Interface;
10+
using TelegramSearchBot.Interface.AI.LLM;
11+
using TelegramSearchBot.Model.AI;
12+
using TelegramSearchBot.Model.Data;
13+
using TelegramSearchBot.Service.AI.LLM;
14+
using Xunit;
15+
16+
namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
17+
public class MiniMaxModelDiscoveryTests {
18+
private static readonly string[] ExpectedFallbackModels = {
19+
"MiniMax-M3",
20+
"MiniMax-M2.7",
21+
"MiniMax-M2.7-highspeed",
22+
"MiniMax-M2.5",
23+
"MiniMax-M2.5-highspeed",
24+
"MiniMax-M2.1",
25+
"MiniMax-M2.1-highspeed",
26+
"MiniMax-M2"
27+
};
28+
29+
[Theory]
30+
[InlineData("https://api.minimaxi.com", "https://api.minimaxi.com/v1")]
31+
[InlineData("https://api.minimaxi.com/", "https://api.minimaxi.com/v1")]
32+
[InlineData("https://api.minimaxi.com/v1", "https://api.minimaxi.com/v1")]
33+
[InlineData("https://api.minimaxi.com/v1/", "https://api.minimaxi.com/v1")]
34+
[InlineData("https://api.minimax.io", "https://api.minimax.io/v1")]
35+
[InlineData("https://api.minimax.io/v1/", "https://api.minimax.io/v1")]
36+
public void NormalizeOpenAIEndpoint_MiniMax_AppendsV1ExactlyOnce(string gateway, string expected) {
37+
var channel = new LLMChannel { Provider = LLMProvider.MiniMax, Gateway = gateway };
38+
39+
Assert.Equal(expected, OpenAIService.NormalizeOpenAIEndpoint(channel));
40+
}
41+
42+
[Fact]
43+
public void NormalizeOpenAIEndpoint_OtherProvider_PreservesGateway() {
44+
var channel = new LLMChannel { Provider = LLMProvider.OpenAI, Gateway = "https://example.com/custom/" };
45+
46+
Assert.Equal("https://example.com/custom/", OpenAIService.NormalizeOpenAIEndpoint(channel));
47+
}
48+
49+
[Fact]
50+
public async Task GetAllModels_MiniMax_UsesDiscoveredAccountModels() {
51+
var handler = new StubHandler(HttpStatusCode.OK, "{\"data\":[{\"id\":\"MiniMax-M3\"},{\"id\":\"account-only-model\"}]}");
52+
var service = CreateService(handler);
53+
54+
var models = (await service.GetAllModels(CreateChannel("https://api.minimaxi.com"))).ToArray();
55+
56+
Assert.Equal(new[] { "MiniMax-M3", "account-only-model" }, models);
57+
Assert.Equal("https://api.minimaxi.com/v1/models", handler.RequestUri?.AbsoluteUri);
58+
}
59+
60+
[Theory]
61+
[InlineData(HttpStatusCode.OK, "{\"data\":[]}")]
62+
[InlineData(HttpStatusCode.BadGateway, "upstream unavailable")]
63+
public async Task GetAllModels_MiniMax_UsesOfficialFallback_WhenDiscoveryUnavailable(HttpStatusCode statusCode, string content) {
64+
var handler = new StubHandler(statusCode, content);
65+
var service = CreateService(handler);
66+
67+
var models = (await service.GetAllModels(CreateChannel("https://api.minimaxi.com/v1"))).ToArray();
68+
69+
Assert.Equal(ExpectedFallbackModels, models);
70+
Assert.Equal("https://api.minimaxi.com/v1/models", handler.RequestUri?.AbsoluteUri);
71+
}
72+
73+
private static OpenAIService CreateService(StubHandler handler) {
74+
var factory = new Mock<IHttpClientFactory>();
75+
factory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(() => new HttpClient(handler, false));
76+
return new OpenAIService(
77+
null,
78+
Mock.Of<ILogger<OpenAIService>>(),
79+
Mock.Of<IMessageExtensionService>(),
80+
factory.Object);
81+
}
82+
83+
private static LLMChannel CreateChannel(string gateway) {
84+
return new LLMChannel {
85+
Provider = LLMProvider.MiniMax,
86+
Gateway = gateway,
87+
ApiKey = "test-key"
88+
};
89+
}
90+
91+
private sealed class StubHandler : HttpMessageHandler {
92+
private readonly HttpStatusCode _statusCode;
93+
private readonly string _content;
94+
95+
public StubHandler(HttpStatusCode statusCode, string content) {
96+
_statusCode = statusCode;
97+
_content = content;
98+
}
99+
100+
public Uri? RequestUri { get; private set; }
101+
102+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
103+
RequestUri = request.RequestUri;
104+
return Task.FromResult(new HttpResponseMessage(_statusCode) {
105+
Content = new StringContent(_content)
106+
});
107+
}
108+
}
109+
}
110+
}

TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ internal static bool IsMiniMaxCompatibleEndpoint(LLMChannel channel, string mode
8585
model.Contains("minimax", StringComparison.OrdinalIgnoreCase);
8686
}
8787

88+
internal static string NormalizeOpenAIEndpoint(LLMChannel channel) {
89+
var gateway = channel?.Gateway ?? string.Empty;
90+
if (channel?.Provider != LLMProvider.MiniMax) {
91+
return gateway;
92+
}
93+
94+
gateway = gateway.TrimEnd('/');
95+
return !string.IsNullOrEmpty(gateway) &&
96+
!gateway.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)
97+
? $"{gateway}/v1"
98+
: gateway;
99+
}
100+
88101
private static string SanitizeAndTruncateArguments(string arguments, int maxChars = 2048) {
89102
if (string.IsNullOrWhiteSpace(arguments)) {
90103
return string.Empty;
@@ -242,9 +255,9 @@ public virtual async Task<IEnumerable<string>> GetAllModels(LLMChannel channel)
242255
return new List<string>();
243256
}
244257

245-
// MiniMax 使用预定义模型列表
246258
if (channel.Provider == LLMProvider.MiniMax) {
247-
return _miniMaxModels;
259+
var models = await GetGenericOpenAICompatibleModels(channel);
260+
return models.Any() ? models : _miniMaxModels;
248261
}
249262

250263
// 检查是否为OpenRouter
@@ -269,7 +282,7 @@ public virtual async Task<IEnumerable<string>> GetAllModels(LLMChannel channel)
269282

270283
// --- Client Setup ---
271284
var clientOptions = new OpenAIClientOptions {
272-
Endpoint = new Uri(channel.Gateway),
285+
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)),
273286
Transport = new HttpClientPipelineTransport(httpClient),
274287
};
275288

@@ -297,13 +310,13 @@ private async Task<IEnumerable<string>> GetGenericOpenAICompatibleModels(LLMChan
297310
}
298311

299312
// 构建模型列表 URL,确保路径正确
300-
var gatewayBase = channel.Gateway.TrimEnd('/');
313+
var gatewayBase = NormalizeOpenAIEndpoint(channel);
301314
var modelsUrl = gatewayBase.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)
302315
? $"{gatewayBase}/models"
303316
: $"{gatewayBase}/v1/models";
304317

305318
var response = await httpClient.GetAsync(modelsUrl);
306-
if (!response.IsSuccessStatusCode) {
319+
if (!response.IsSuccessStatusCode && channel.Provider != LLMProvider.MiniMax) {
307320
// 尝试不带 /v1 的路径
308321
var altUrl = $"{gatewayBase}/models";
309322
if (altUrl != modelsUrl) {
@@ -348,16 +361,17 @@ private bool IsOpenRouter(string gateway) {
348361
}
349362

350363
/// <summary>
351-
/// MiniMax预定义模型列表
364+
/// MiniMax OpenAI-compatible text model fallback snapshot.
352365
/// </summary>
353366
private static readonly string[] _miniMaxModels = {
367+
"MiniMax-M3",
368+
"MiniMax-M2.7",
369+
"MiniMax-M2.7-highspeed",
354370
"MiniMax-M2.5",
355371
"MiniMax-M2.5-highspeed",
356372
"MiniMax-M2.1",
357373
"MiniMax-M2.1-highspeed",
358-
"MiniMax-M2",
359-
"image-01",
360-
"image-01-live"
374+
"MiniMax-M2"
361375
};
362376

363377
/// <summary>
@@ -404,7 +418,8 @@ public virtual async Task<IEnumerable<ModelWithCapabilities>> GetAllModelsWithCa
404418
}
405419

406420
if (channel.Provider == LLMProvider.MiniMax) {
407-
return _miniMaxModels.Select(InferOpenAIModelCapabilities);
421+
var models = await GetAllModels(channel);
422+
return models.Select(InferOpenAIModelCapabilities);
408423
}
409424

410425
// 检查是否为OpenRouter
@@ -439,7 +454,7 @@ public virtual async Task<IEnumerable<ModelWithCapabilities>> GetAllModelsWithCa
439454

440455
// 如果内部API失败,使用标准API并根据模型名称推断能力
441456
var clientOptions = new OpenAIClientOptions {
442-
Endpoint = new Uri(channel.Gateway),
457+
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)),
443458
Transport = new HttpClientPipelineTransport(httpClient),
444459
};
445460

@@ -1151,7 +1166,7 @@ private async IAsyncEnumerable<string> ExecWithNativeToolCallingAsync(
11511166

11521167
using var client = _httpClientFactory.CreateClient();
11531168
var clientOptions = new OpenAIClientOptions {
1154-
Endpoint = new Uri(channel.Gateway),
1169+
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)),
11551170
Transport = new HttpClientPipelineTransport(client),
11561171
};
11571172
var chatClient = new ChatClient(model: modelName, credential: new(channel.ApiKey), clientOptions);
@@ -1445,7 +1460,7 @@ private async IAsyncEnumerable<string> ExecWithXmlToolCallingAsync(
14451460

14461461
using var client = _httpClientFactory.CreateClient();
14471462
var clientOptions = new OpenAIClientOptions {
1448-
Endpoint = new Uri(channel.Gateway),
1463+
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)),
14491464
Transport = new HttpClientPipelineTransport(client),
14501465
};
14511466
var chatClient = new ChatClient(model: modelName, credential: new(channel.ApiKey), clientOptions);
@@ -1614,7 +1629,7 @@ public async IAsyncEnumerable<string> ResumeFromSnapshotAsync(LlmContinuationSna
16141629

16151630
using var client = _httpClientFactory.CreateClient();
16161631
var clientOptions = new OpenAIClientOptions {
1617-
Endpoint = new Uri(channel.Gateway),
1632+
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)),
16181633
Transport = new HttpClientPipelineTransport(client),
16191634
};
16201635
var chatClient = new ChatClient(model: modelName, credential: new(channel.ApiKey), clientOptions);
@@ -1915,7 +1930,7 @@ public async Task<float[]> GenerateEmbeddingsAsync(string text, string modelName
19151930
using var httpClient = _httpClientFactory.CreateClient();
19161931

19171932
var clientOptions = new OpenAIClientOptions {
1918-
Endpoint = new Uri(channel.Gateway),
1933+
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)),
19191934
Transport = new HttpClientPipelineTransport(httpClient),
19201935
};
19211936

@@ -2022,7 +2037,7 @@ public async Task<string> AnalyzeImageAsync(string photoPath, string modelName,
20222037
using var httpClient = _httpClientFactory.CreateClient();
20232038

20242039
var clientOptions = new OpenAIClientOptions {
2025-
Endpoint = new Uri(channel.Gateway),
2040+
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)),
20262041
Transport = new HttpClientPipelineTransport(httpClient),
20272042
};
20282043

TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ public EditLLMConfHelperTest() {
7979
// 新增 ILLMFactory mock
8080
var llmFactoryMock = new Mock<ILLMFactory>();
8181
llmFactoryMock.Setup(f => f.GetLLMService(LLMProvider.OpenAI)).Returns(_openAIServiceMock.Object);
82+
llmFactoryMock.Setup(f => f.GetLLMService(LLMProvider.MiniMax)).Returns(_openAIServiceMock.Object);
8283
llmFactoryMock.Setup(f => f.GetLLMService(LLMProvider.Ollama)).Returns(_ollamaServiceMock.Object);
8384
llmFactoryMock.Setup(f => f.GetLLMService(LLMProvider.Gemini)).Returns(_geminiServiceMock.Object);
8485

@@ -160,6 +161,26 @@ await _context.ChannelsWithModel.AddAsync(new ChannelWithModel {
160161
Assert.Equal(2, count); // 1 restored + 1 added
161162
}
162163

164+
[Fact]
165+
public async Task RefreshAllChannel_MiniMax_ShouldPreserveMissingManualModel() {
166+
var channel = new LLMChannel { Id = 14, Name = "MiniMax", Provider = LLMProvider.MiniMax };
167+
await _context.LLMChannels.AddAsync(channel);
168+
await _context.ChannelsWithModel.AddAsync(new ChannelWithModel {
169+
LLMChannelId = 14,
170+
ModelName = "legacy-or-account-specific-model",
171+
IsDeleted = false
172+
});
173+
await _context.SaveChangesAsync();
174+
175+
await _helper.RefreshAllChannel();
176+
177+
var manualModel = await _context.ChannelsWithModel
178+
.FirstAsync(m => m.LLMChannelId == 14 && m.ModelName == "legacy-or-account-specific-model");
179+
Assert.False(manualModel.IsDeleted);
180+
Assert.Contains(await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 14).ToListAsync(),
181+
m => m.ModelName == "openai-model1" && !m.IsDeleted);
182+
}
183+
163184
[Fact]
164185
public async Task GetModelsByChannelId_ShouldNotReturnDeletedModels() {
165186
// Arrange

TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,11 @@ public async Task<int> RefreshAllChannel() {
133133
.Where(x => !x.IsDeleted)
134134
.Select(x => x.ModelName)
135135
.ToHashSet();
136-
var toDelete = existingRecords
137-
.Where(x => !x.IsDeleted && !modelSet.Contains(x.ModelName))
138-
.ToList();
136+
var toDelete = channel.Provider == LLMProvider.MiniMax
137+
? new List<ChannelWithModel>()
138+
: existingRecords
139+
.Where(x => !x.IsDeleted && !modelSet.Contains(x.ModelName))
140+
.ToList();
139141
foreach (var record in toDelete) {
140142
record.IsDeleted = true;
141143
_logger.LogInformation("通道 {ChannelName} 标记删除消失的模型 {ModelName}", channel.Name, record.ModelName);

0 commit comments

Comments
 (0)