Merge pull request #6758 from DuskLin/feat/minimax-platform

feat(minimax): 增加 MiniMax 平台支持
This commit is contained in:
Wesley Liddick
2026-09-08 18:50:06 +08:00
committed by GitHub
109 changed files with 850 additions and 214 deletions
+2 -1
View File
@@ -182,6 +182,7 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
)
func (pr Provider) String() string {
@@ -191,7 +192,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax:
return nil
default:
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
@@ -108,6 +108,7 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
)
func (pr Provider) String() string {
@@ -117,7 +118,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax:
return nil
default:
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
+2 -2
View File
@@ -623,7 +623,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}},
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
@@ -776,7 +776,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "extra_headers", Type: field.TypeJSON},
+1 -1
View File
@@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field {
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
// check_mode: 'probe' | 'quota' | 'quota_probe'
// probe - LLM 探活(默认,原有行为)
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
@@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+1 -1
View File
@@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek":
"kimi", "zhipu", "deepseek", "minimax":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+2 -1
View File
@@ -2046,7 +2046,8 @@ func setDefaults() {
"api.moonshot.ai",
"api.moonshot.cn",
"open.bigmodel.cn",
"api.minimaxi.com",
"api.minimaxi.com", // MiniMax CN quota + inference
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
"generativelanguage.googleapis.com",
"cloudcode-pa.googleapis.com",
"*.openai.azure.com",
+2 -1
View File
@@ -27,6 +27,7 @@ const (
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
PlatformComposite = "composite"
)
@@ -43,7 +44,7 @@ const (
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi / minimax 原生端点,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
@@ -643,6 +643,7 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -549,7 +549,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
svc := service.NewPricingService(nil, nil)
router := setupSyncPricingModelsRouter(svc)
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} {
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -258,7 +258,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -332,7 +332,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "composite",
"kimi", "zhipu", "deepseek", "minimax", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
@@ -72,7 +72,7 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
}
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
@@ -35,6 +35,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
{model: "k3", platform: service.PlatformKimi},
{model: "glm-5.2", platform: service.PlatformZhipu},
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
{model: "MiniMax-M3", platform: service.PlatformMiniMax},
}
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
for _, provider := range providers {
@@ -56,7 +57,7 @@ func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testi
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
for _, platform := range []string{
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformAnthropic, service.PlatformGemini,
} {
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
+4 -2
View File
@@ -1278,7 +1278,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
seen := make(map[string]struct{})
models := make([]string, 0)
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
@@ -1441,6 +1441,8 @@ func defaultCodexModelIDsForPlatform(platform string) []string {
switch platform {
case service.PlatformDeepseek:
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
case service.PlatformMiniMax:
return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}
default:
return defaultModelIDsForPlatform(platform)
}
@@ -1470,7 +1472,7 @@ func defaultModelIDsForPlatform(platform string) []string {
case service.PlatformComposite:
ids := make([]string, 0)
seen := make(map[string]struct{})
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
@@ -791,6 +791,13 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
},
},
{
ID: 7,
Platform: service.PlatformMiniMax,
Credentials: map[string]any{
"model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"},
},
},
},
},
},
@@ -805,7 +812,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
Platform: service.PlatformComposite,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"},
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"},
},
},
})
@@ -816,7 +823,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
@@ -869,6 +876,7 @@ func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testin
{ID: 2, Platform: service.PlatformKimi},
{ID: 3, Platform: service.PlatformZhipu},
{ID: 4, Platform: service.PlatformDeepseek},
{ID: 5, Platform: service.PlatformMiniMax},
},
},
},
@@ -900,13 +908,14 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
for _, model := range claude.DefaultModels {
want = append(want, model.ID)
}
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
}
}
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
}
@@ -18,7 +18,7 @@ import (
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} {
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformGrok} {
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
@@ -322,7 +322,7 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
return compositeTargetPlatformAllowed(c, apiKey, model,
service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek)
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax)
}
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
@@ -44,6 +44,7 @@ const (
PlatformKimi = domain.PlatformKimi
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
)
// AllPlatforms 返回所有支持的平台列表
@@ -57,6 +58,7 @@ func AllPlatforms() []string {
PlatformKimi,
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
}
}
@@ -16,5 +16,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"kimi",
"zhipu",
"deepseek",
"minimax",
}, AllPlatforms())
}
@@ -18,7 +18,7 @@ const (
// ollamaCloudUsagePlatformsSQL 是 service.isOllamaCloudUsagePlatform 的 SQL
// 镜像:Ollama Cloud key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台
// 下复用。所有平台白名单 SQL 只允许引用本常量,不得各处重写字面量,防止漂移。
ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'"
ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'"
ollamaCloudUsageEligibleSQL = `
platform IN (` + ollamaCloudUsagePlatformsSQL + `)
AND type = 'apikey'
@@ -179,7 +179,7 @@ func TestListOllamaCloudUsageGroupAccountsUsesOneStrictBatchQuery(t *testing.T)
require.Empty(t, accounts)
query := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, query, "credentials ->> 'api_key' = ANY($1)")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek')")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')")
require.Contains(t, query, "jsonb_typeof(credentials -> 'api_key') = 'string'")
require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
require.NotContains(t, query, "~*")
@@ -207,7 +207,7 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) {
for _, clause := range []string{
"deleted_at IS NULL",
"status = 'active'",
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek')",
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')",
"type = 'apikey'",
ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"),
"jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'",
@@ -251,7 +251,7 @@ func TestBulkUpdateOllamaIdentityCleanupIsValueConditional(t *testing.T) {
require.Contains(t, query, "NOT ("+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'"))
require.NotContains(t, query, "~*")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek') AND type = 'apikey'")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey'")
require.Contains(t, query, "- 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
payload, ok := exec.execArgs[0][0].([]byte)
require.True(t, ok)
@@ -326,10 +326,10 @@ func TestOllamaCloudUsagePlatformWhitelistMatchesServicePredicate(t *testing.T)
for _, match := range matches {
sqlPlatforms[match[1]] = struct{}{}
}
require.Len(t, sqlPlatforms, 5)
require.Len(t, sqlPlatforms, 6)
for _, platform := range []string{
service.PlatformOpenAI, service.PlatformAnthropic,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformGemini, service.PlatformGrok, service.PlatformAntigravity,
service.PlatformComposite, "kiro",
} {
@@ -373,7 +373,7 @@ func TestUpdateCredentialsPlainCNAPIKeyAccountCleanupStaysSemanticallyEquivalent
require.NoError(t, err)
query := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, query,
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb")
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb")
require.Contains(t, query,
"THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
require.NotContains(t, query, "- 'upstream_billing_probe_enabled'")
@@ -33,7 +33,7 @@ func TestProxyUpdateInvalidatesBoundProbeSnapshotsAndEnqueuesOutboxAtomically(t
WithArgs(int64(9)).
WillReturnResult(sqlmock.NewResult(0, 0))
expectProxyUpdateReload(mock, 9, "new.example", "user", "pass")
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
WithArgs(int64(9)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)).AddRow(int64(18)))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
@@ -76,7 +76,7 @@ func TestProxyUpdateRollsBackWhenProbeInvalidationOutboxFails(t *testing.T) {
WithArgs(int64(9)).
WillReturnResult(sqlmock.NewResult(0, 0))
expectProxyUpdateReload(mock, 9, "new.example", "", "")
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
WithArgs(int64(9)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
@@ -116,11 +116,12 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed(t *tes
{UserID: userID, Platform: "kimi", DailyLimitUSD: &daily},
{UserID: userID, Platform: "zhipu"},
{UserID: userID, Platform: "deepseek"},
{UserID: userID, Platform: "minimax"},
}
require.NoError(t, repo.BulkInsertInitial(txCtx, records),
"kimi/zhipu/deepseek 平台应可写入(迁移 224 后 CHECK 约束已含国产供应商)")
"kimi/zhipu/deepseek/minimax 平台应可写入(CHECK 约束已含国产供应商)")
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
rec, err := repo.GetByUserPlatform(txCtx, userID, platform)
require.NoError(t, err)
require.NotNil(t, rec, "%s 配额行应已写入", platform)
+2 -2
View File
@@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -1180,7 +1180,7 @@ func TestAPIContracts(t *testing.T) {
"purchase_subscription_url": "",
"table_default_page_size": 20,
"table_page_size_options": [10, 20, 50],
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
+3 -3
View File
@@ -48,8 +48,8 @@ func RegisterGatewayRoutes(
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 openai/grok 一样经 OpenAI 网关转发。
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax:
// 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。
return true
default:
return false
@@ -57,7 +57,7 @@ func RegisterGatewayRoutes(
}
countTokensHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax:
h.OpenAIGateway.CountTokens(c)
case service.PlatformGrok:
h.OpenAIGateway.GrokCountTokens(c)
+26 -9
View File
@@ -285,17 +285,20 @@ func (a *Account) IsDeepseek() bool {
return a.Platform == PlatformDeepseek
}
// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
func (a *Account) IsMiniMax() bool {
return a.Platform == PlatformMiniMax
}
// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。
func (a *Account) IsCNProvider() bool {
return a != nil && IsCNProvider(a.Platform)
}
// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
// openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions
// openai/grok 原生走 OpenAI 网关;国产供应商同为 OpenAI Chat Completions
// 兼容上游,也经 OpenAI 网关转发。
func (a *Account) IsOpenAICompatible() bool {
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok ||
a.Platform == PlatformKimi || a.Platform == PlatformZhipu || a.Platform == PlatformDeepseek)
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider())
}
func (a *Account) GeminiOAuthType() string {
@@ -1362,6 +1365,8 @@ func (a *Account) GetOpenAIBaseURL() string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
default:
return "https://api.openai.com"
}
@@ -1387,7 +1392,7 @@ func (a *Account) IsCodingPlan() bool {
// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi / minimax 支持(官方原生
// Responses 端点,适配 Codex);zhipu 无此端点。
func (a *Account) GetAPIProtocol() string {
if a == nil || !a.IsCNProvider() {
@@ -1410,13 +1415,13 @@ func (a *Account) GetAPIProtocol() string {
// SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。
// DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为
// /v1/responses(moonshot.cn / kimi.com/coding)。
// /v1/responses(moonshot.cn / kimi.com/coding);MiniMax 为 /v1/responses。
func (a *Account) SupportsNativeCNResponses() bool {
if a == nil {
return false
}
switch a.Platform {
case PlatformDeepseek, PlatformKimi:
case PlatformDeepseek, PlatformKimi, PlatformMiniMax:
return true
default:
return false
@@ -1477,6 +1482,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
case PlatformMiniMax:
return DefaultMiniMaxAnthropicBaseURL
}
case APIProtocolChatCompletions, APIProtocolResponses:
switch a.Platform {
@@ -1492,6 +1499,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
}
}
return ""
@@ -1528,6 +1537,8 @@ func (a *Account) GetAnthropicProtocolBaseURL() string {
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
case PlatformMiniMax:
return DefaultMiniMaxAnthropicBaseURL
default:
return ""
}
@@ -1555,6 +1566,8 @@ func (a *Account) GetOpenAIFormatBaseURL() string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
default:
return a.GetOpenAIBaseURL()
}
@@ -1569,9 +1582,9 @@ func (a *Account) GetCNAPIKey() string {
return a.GetCredential("api_key")
}
// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu),
// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu / minimax),
// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
// 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。
// 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。
func (a *Account) GetCodingPlanProvider() string {
if a == nil || a.GetAccountMode() != AccountModeCoding {
return ""
@@ -1582,6 +1595,10 @@ func (a *Account) GetCodingPlanProvider() string {
return PlatformKimi
case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"):
return PlatformZhipu
case strings.Contains(baseURL, "minimax.io"),
strings.Contains(baseURL, "minimaxi.com"),
strings.Contains(baseURL, "minimax.com"):
return PlatformMiniMax
default:
return ""
}
@@ -78,7 +78,7 @@ func (a *Account) IsHeaderOverrideEligible() bool {
return false
}
switch a.Platform {
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return a.Type == AccountTypeAPIKey
case PlatformGrok:
return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth
@@ -58,10 +58,8 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string]
winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now)
case PlatformGrok:
winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now)
case PlatformKimi:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformKimi), threshold, now)
case PlatformZhipu:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformZhipu), threshold, now)
case PlatformKimi, PlatformZhipu, PlatformMiniMax:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now)
default:
return decision
}
+1 -1
View File
@@ -515,7 +515,7 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
case PlatformGrok:
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
return nil
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
// 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
return nil
default:
+1 -1
View File
@@ -314,7 +314,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
func compositeDefaultModelsListCandidateIDs() []string {
seen := make(map[string]struct{})
ids := make([]string, 0)
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
for _, id := range defaultModelsListCandidateIDs(platform) {
if _, ok := seen[id]; ok {
continue
@@ -213,7 +213,7 @@ func TestAdminService_CNProviderModelsListCandidatesKeepClaudeDefaults(t *testin
for _, model := range claude.DefaultModels {
want = append(want, model.ID)
}
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
require.Equal(t, want, defaultModelsListCandidateIDs(platform), "platform=%s", platform)
}
}
@@ -175,6 +175,7 @@ var providerAdapters = map[string]providerAdapter{
MonitorProviderKimi: providerKimiChatAdapter,
MonitorProviderZhipu: providerZhipuChatAdapter,
MonitorProviderDeepseek: providerDeepseekChatAdapter,
MonitorProviderMiniMax: providerMiniMaxChatAdapter,
MonitorProviderAnthropic: {
buildPath: func(string) string { return providerAnthropicPath },
buildBody: func(model, prompt string) ([]byte, error) {
@@ -226,6 +227,9 @@ var providerZhipuChatAdapter = newOpenAICompatibleChatAdapter(providerZhipuPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerDeepseekChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerMiniMaxChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
func newOpenAICompatibleChatAdapter(path string) providerAdapter {
return providerAdapter{
buildPath: func(string) string { return path },
@@ -458,6 +462,7 @@ var bodyMergeKeyDenyList = map[string]map[string]bool{
MonitorProviderKimi: {"model": true, "messages": true, "stream": true},
MonitorProviderZhipu: {"model": true, "messages": true, "stream": true},
MonitorProviderDeepseek: {"model": true, "messages": true, "stream": true},
MonitorProviderMiniMax: {"model": true, "messages": true, "stream": true},
}
func checkAPIMode(opts *CheckOptions) string {
@@ -479,7 +484,7 @@ func bodyMergeDenyKey(provider, apiMode string) string {
func isOpenAICompatibleChatProvider(provider string) bool {
switch provider {
case MonitorProviderOpenAI, MonitorProviderGrok,
MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek:
MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, MonitorProviderMiniMax:
return true
default:
return false
@@ -69,6 +69,7 @@ const (
MonitorProviderKimi = "kimi"
MonitorProviderZhipu = "zhipu"
MonitorProviderDeepseek = "deepseek"
MonitorProviderMiniMax = "minimax"
// MonitorCheckMode 检测模式(channel_monitors.check_mode)。
// probe - LLM 探活(默认,原有行为)
@@ -151,7 +152,7 @@ var (
"CHANNEL_MONITOR_NOT_FOUND", "channel monitor not found",
)
ErrChannelMonitorInvalidProvider = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek",
"CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek/minimax",
)
ErrChannelMonitorInvalidCheckMode = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_CHECK_MODE", "check_mode must be one of probe/quota/quota_probe; antigravity only supports quota",
@@ -163,7 +164,7 @@ var (
"CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform",
)
ErrChannelMonitorAccountNotSupportable = infraerrors.BadRequest(
"CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)",
"CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu/minimax, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)",
)
ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai",
@@ -216,7 +216,7 @@ func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountI
// (GetUsageForAccount / QueryUsageForAccount / QueryBalanceForAccount),
// 下游服务不再各自 GetByID(每次含 proxies/groups 联查)。
switch account.Platform {
case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek:
case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek, domain.PlatformMiniMax:
if account.IsCodingPlan() {
return f.fetchCNQuota(ctx, account, now)
}
@@ -173,6 +173,30 @@ func TestQuotaFetcher_CodingPlanAccountUsesCNQuota(t *testing.T) {
require.Equal(t, 0, cnBalance.calls)
}
func TestQuotaFetcher_MiniMaxCodingPlanUsesCNQuota(t *testing.T) {
fetcher, usage, cnQuota, cnBalance, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[19] = &Account{
ID: 19,
Platform: domain.PlatformMiniMax,
Credentials: map[string]any{"account_mode": AccountModeCoding},
}
cnQuota.result = &CNProviderQuotaProbeResult{
Success: true,
CredentialValid: true,
Tiers: []CNQuotaTier{
{Window: "5h", UsedPercent: 12},
},
}
snapshot := fetcher.Fetch(context.Background(), 19)
require.True(t, snapshot.Success)
require.Equal(t, "cn_quota", snapshot.Source)
require.Equal(t, 1, cnQuota.calls)
require.Equal(t, 0, cnBalance.calls)
require.Equal(t, 0, usage.getCalls())
}
func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) {
fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t)
accounts.accounts[11] = &Account{
@@ -458,11 +458,26 @@ func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) {
name: "zhipu coding default endpoint ok",
account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}},
},
{
name: "minimax coding default endpoint ok",
account: &Account{ID: 14, Platform: domain.PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModeCoding}},
},
{
name: "custom-domain minimax coding unsupported",
account: &Account{ID: 16, Platform: domain.PlatformMiniMax, Type: AccountTypeAPIKey,
Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://relay.example.com/v1"}},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "zhipu payg has no balance endpoint",
account: &Account{ID: 5, Platform: domain.PlatformZhipu},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "minimax payg has no balance endpoint",
account: &Account{ID: 15, Platform: domain.PlatformMiniMax},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "kimi payg ok",
account: &Account{ID: 6, Platform: domain.PlatformKimi},
@@ -211,22 +211,22 @@ func normalizeMonitorPrimaryModel(provider, checkMode, model string) string {
// monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与
// fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 /
// 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合:
// - kimi/zhipu/deepseek coding:GetCodingPlanProvider 须识别为 kimi/zhipu
// (deepseek coding、自定义域名 kimi coding 无法路由额度端点)
// - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无)
// - kimi/zhipu/deepseek/minimax coding:GetCodingPlanProvider 须识别官方域名
// (deepseek coding、自定义中转、minimax payg 无法路由额度端点)
// - kimi/zhipu/deepseek/minimax payg:仅 kimi/deepseek 有公开余额端点
// - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error)
// - openai:OAuth(API-Key 型无 usage 通道)
// - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行
func monitorAccountQuotaCapability(account *Account) error {
switch account.Platform {
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
if account.IsCodingPlan() {
if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu {
if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu && p != PlatformMiniMax {
return ErrChannelMonitorAccountNotSupportable
}
return nil
}
if account.Platform == PlatformZhipu {
if account.Platform == PlatformZhipu || account.Platform == PlatformMiniMax {
return ErrChannelMonitorAccountNotSupportable
}
return nil
+1 -1
View File
@@ -357,7 +357,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool {
// fallback used before a request target has been resolved.
func matchingPlatforms(groupPlatform string) []string {
if groupPlatform == PlatformComposite {
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
}
return []string{groupPlatform}
}
@@ -2069,7 +2069,7 @@ func TestMatchingPlatforms(t *testing.T) {
{"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}},
{"gemini returns itself", PlatformGemini, []string{PlatformGemini}},
{"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}},
}
for _, tt := range tests {
@@ -126,9 +126,9 @@ func (s *CNProviderBalanceCheckService) runOnce() {
quotaTargets = append(quotaTargets, quotaTarget{id: account.ID, platform: account.Platform})
continue
}
// payg 余额探测仅 kimi/deepseek(智谱无公开余额端点,payg 账号
// 依赖响应式 402/429 处理)。
if platform != PlatformZhipu && account.Schedulable {
// payg 余额探测仅 kimi/deepseek(智谱 / MiniMax 无公开余额端点,
// payg 账号依赖响应式 402/429 处理)。
if platform != PlatformZhipu && platform != PlatformMiniMax && account.Schedulable {
paygTargets = append(paygTargets, account)
}
}
@@ -141,13 +141,15 @@ func (s *CNProviderBalanceCheckService) runOnce() {
}
collect(platform, accounts)
}
// 智谱无余额端点,仅进额度探测。
// 智谱 / MiniMax 无余额端点,仅进额度探测。
if s.quotaService != nil {
accounts, err := s.accountRepo.ListByPlatform(context.Background(), PlatformZhipu)
if err != nil {
log.Printf("[CNBalance] list %s accounts failed: %v", PlatformZhipu, err)
} else {
collect(PlatformZhipu, accounts)
for _, platform := range []string{PlatformZhipu, PlatformMiniMax} {
accounts, err := s.accountRepo.ListByPlatform(context.Background(), platform)
if err != nil {
log.Printf("[CNBalance] list %s accounts failed: %v", platform, err)
continue
}
collect(platform, accounts)
}
}
@@ -49,10 +49,13 @@ func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) {
Credentials: map[string]any{"account_mode": "coding"}}
zhipuCoding := Account{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive,
Credentials: map[string]any{"account_mode": "coding"}}
minimaxCoding := Account{ID: 5, Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Status: StatusActive,
Credentials: map[string]any{"account_mode": "coding"}}
repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{
PlatformKimi: {kimiActive, kimiPaused, kimiInactive},
PlatformZhipu: {zhipuCoding},
PlatformKimi: {kimiActive, kimiPaused, kimiInactive},
PlatformZhipu: {zhipuCoding},
PlatformMiniMax: {minimaxCoding},
}}
prober := &fakeCNQuotaProber{}
svc := &CNProviderBalanceCheckService{
@@ -63,7 +66,7 @@ func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) {
svc.runOnce()
require.ElementsMatch(t, []int64{1, 2, 4}, prober.probed)
require.ElementsMatch(t, []int64{1, 2, 4, 5}, prober.probed)
}
// runOnceZhipuQuota 在 quotaService 缺失时安全跳过(Start 门控不启动的老部署路径)。
@@ -46,6 +46,7 @@ func TestValidateCodingPlanAccount_Matrix(t *testing.T) {
{name: "payg has no quota endpoint", account: paygAccount(PlatformKimi), wantReason: "CN_QUOTA_NOT_CODING_PLAN"},
{name: "kimi coding ok", account: codingAccount(PlatformKimi)},
{name: "zhipu coding ok", account: codingAccount(PlatformZhipu)},
{name: "minimax coding ok", account: codingAccount(PlatformMiniMax)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -129,8 +129,8 @@ func (s *CNProviderQuotaService) QueryUsageForAccount(ctx context.Context, accou
func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) {
provider := account.GetCodingPlanProvider()
if provider != PlatformKimi && provider != PlatformZhipu {
return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu coding plan account")
if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax {
return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu/minimax coding plan account")
}
apiKey := strings.TrimSpace(account.GetCNAPIKey())
@@ -158,6 +158,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
if zhipuOrg != "" {
targetURL += "?type=2"
}
case PlatformMiniMax:
targetURL = minimaxQuotaURL(baseURL)
authHeader = "Bearer " + apiKey
}
// 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验):
@@ -177,7 +180,7 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
}
req.Header.Set("Authorization", authHeader)
req.Header.Set("Accept", "application/json")
if provider == PlatformZhipu {
if provider == PlatformZhipu || provider == PlatformMiniMax {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "en-US,en")
if zhipuOrg != "" {
@@ -234,6 +237,17 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
case PlatformZhipu:
tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data"))
result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String())
case PlatformMiniMax:
if status := gjson.GetBytes(bodyBytes, "base_resp.status_code"); status.Exists() && status.Int() != 0 {
msg := strings.TrimSpace(gjson.GetBytes(bodyBytes, "base_resp.status_msg").String())
if msg == "" {
msg = "unknown minimax quota error"
}
result.Error = fmt.Sprintf("API error (%d): %s", status.Int(), msg)
return result, nil
}
tiers = parseMiniMaxUsageTiers(bodyBytes)
result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "current_subscribe_title").String())
}
result.Tiers = tiers
result.Success = true
@@ -305,6 +319,16 @@ func kimiQuotaURL(baseURL string) string {
return base + "/v1/usages"
}
// minimaxQuotaURL 根据推理域名选择 Token Plan / Coding Plan 额度主机。
// 官方 FAQ 写 www.minimax.io / www.minimaxi.com,实际以 Bearer Key 打 api.*。
// 国际站 api.minimax.io;国内站 api.minimaxi.com(含 api.minimax.com 与自定义回落)。
func minimaxQuotaURL(baseURL string) string {
if strings.Contains(strings.ToLower(baseURL), "minimax.io") {
return "https://api.minimax.io/v1/api/openplatform/coding_plan/remains"
}
return "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains"
}
func zhipuQuotaHost(baseURL string) string {
switch u := strings.ToLower(baseURL); {
case strings.Contains(u, "bigmodel.cn"):
@@ -372,6 +396,71 @@ func parseKimiUsageTiers(body []byte) []CNQuotaTier {
return tiers
}
// parseMiniMaxUsageTiers 解析 MiniMax Token Plan / Coding Plan remains 响应。
//
// 只取 model_name == "general"(编程套餐),跳过 video。字段是剩余百分比,
// 展示已用 = 100 - remaining:
// - 5h:current_interval_remaining_percent + end_time
// - 周限额:仅 current_weekly_status == 1 时用 current_weekly_remaining_percent + weekly_end_time
func parseMiniMaxUsageTiers(body []byte) []CNQuotaTier {
remains := gjson.GetBytes(body, "model_remains")
if !remains.IsArray() {
return nil
}
var general gjson.Result
remains.ForEach(func(_, item gjson.Result) bool {
if strings.EqualFold(strings.TrimSpace(item.Get("model_name").String()), "general") {
general = item
return false
}
return true
})
if !general.Exists() {
return nil
}
var tiers []CNQuotaTier
if remaining, ok := cnParseF64(general.Get("current_interval_remaining_percent").Value()); ok {
used := 100 - remaining
if used < 0 {
used = 0
}
tiers = append(tiers, CNQuotaTier{
Window: "5h",
UsedPercent: used,
ResetAt: minimaxResetTime(general.Get("end_time")),
})
}
if general.Get("current_weekly_status").Int() == 1 {
if remaining, ok := cnParseF64(general.Get("current_weekly_remaining_percent").Value()); ok {
used := 100 - remaining
if used < 0 {
used = 0
}
tiers = append(tiers, CNQuotaTier{
Window: "weekly",
UsedPercent: used,
ResetAt: minimaxResetTime(general.Get("weekly_end_time")),
})
}
}
return tiers
}
func minimaxResetTime(v gjson.Result) string {
if !v.Exists() {
return ""
}
ms := v.Int()
if ms <= 0 {
return cnNormalizeResetTime(v.Value())
}
if ms < 1_000_000_000_000 {
return time.Unix(ms, 0).UTC().Format(time.RFC3339)
}
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}
// cnZhipuWindow 标识智谱 TOKENS_LIMIT 条目所属窗口。
type cnZhipuWindow int
@@ -229,6 +229,88 @@ func TestKimiQuotaURL(t *testing.T) {
require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/v1/"))
}
func TestMiniMaxQuotaURL(t *testing.T) {
t.Parallel()
require.Equal(t, "https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
minimaxQuotaURL("https://api.minimax.io/v1"))
require.Equal(t, "https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
minimaxQuotaURL("https://api.minimax.io/anthropic"))
require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
minimaxQuotaURL("https://api.minimaxi.com/v1"))
require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
minimaxQuotaURL("https://api.minimax.com/v1"))
require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
minimaxQuotaURL("https://custom.example.com"))
}
func TestParseMiniMaxUsageTiers(t *testing.T) {
t.Parallel()
endMs := int64(1_700_000_000_000)
weeklyEndMs := endMs + 7*24*60*60*1000
body := []byte(`{
"base_resp": {"status_code": 0},
"current_subscribe_title": "Max",
"model_remains": [
{"model_name": "video", "current_interval_remaining_percent": 10},
{
"model_name": "general",
"current_interval_remaining_percent": 25,
"end_time": 1700000000000,
"current_weekly_status": 1,
"current_weekly_remaining_percent": 40,
"weekly_end_time": 1700604800000
}
]
}`)
tiers := parseMiniMaxUsageTiers(body)
require.Len(t, tiers, 2)
require.Equal(t, "5h", tiers[0].Window)
require.InDelta(t, 75, tiers[0].UsedPercent, 1e-9)
require.Equal(t, time.UnixMilli(endMs).UTC().Format(time.RFC3339), tiers[0].ResetAt)
require.Equal(t, "weekly", tiers[1].Window)
require.InDelta(t, 60, tiers[1].UsedPercent, 1e-9)
require.Equal(t, time.UnixMilli(weeklyEndMs).UTC().Format(time.RFC3339), tiers[1].ResetAt)
noWeekly := parseMiniMaxUsageTiers([]byte(`{
"model_remains": [{
"model_name": "general",
"current_interval_remaining_percent": 80,
"end_time": 1700000000000,
"current_weekly_status": 3,
"current_weekly_remaining_percent": 10
}]
}`))
require.Len(t, noWeekly, 1)
require.Equal(t, "5h", noWeekly[0].Window)
require.InDelta(t, 20, noWeekly[0].UsedPercent, 1e-9)
require.Nil(t, parseMiniMaxUsageTiers([]byte(`{"model_remains":[{"model_name":"video","current_interval_remaining_percent":5}]}`)))
}
func TestGetCodingPlanProvider_MiniMax(t *testing.T) {
t.Parallel()
coding := &Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{
"account_mode": AccountModeCoding,
"base_url": "https://api.minimaxi.com/v1",
}}
require.Equal(t, PlatformMiniMax, coding.GetCodingPlanProvider())
intl := &Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{
"account_mode": AccountModeCoding,
"base_url": "https://api.minimax.io/anthropic",
}}
require.Equal(t, PlatformMiniMax, intl.GetCodingPlanProvider())
// 未配 base_url 时走国内站默认域名,仍能识别官方额度端点。
require.Equal(t, PlatformMiniMax, (&Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{
"account_mode": AccountModeCoding,
}}).GetCodingPlanProvider())
require.Empty(t, (&Account{Platform: PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModePayG}}).GetCodingPlanProvider())
// 自定义中转不得把第三方 Key 发往官方额度端点。
require.Empty(t, (&Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{
"account_mode": AccountModeCoding,
"base_url": "https://relay.example.com/v1",
}}).GetCodingPlanProvider())
}
// TestCNBalanceURL Kimi 固定端点;DeepSeek 基于 base_url 拼接。
func TestCNBalanceURL(t *testing.T) {
t.Parallel()
@@ -309,6 +391,28 @@ func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) {
require.True(t, reset.Equal(*decision.Until))
}
func TestEvaluateAccountSchedulingThreshold_MiniMaxCodingPlan(t *testing.T) {
t.Parallel()
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
reset := now.Add(3 * time.Hour)
account := &Account{
Platform: PlatformMiniMax,
Extra: map[string]any{
"minimax_5h_used_percent": 90.0,
"minimax_5h_reset_at": reset.Format(time.RFC3339),
"minimax_weekly_used_percent": 30.0,
"minimax_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339),
},
}
decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformMiniMax: 80}, now)
require.True(t, decision.ShouldPause)
require.Equal(t, PlatformMiniMax, decision.Platform)
require.Equal(t, "5h", decision.Window)
require.InDelta(t, 90.0, decision.UsedPercent, 1e-9)
require.NotNil(t, decision.Until)
require.True(t, reset.Equal(*decision.Until))
}
// TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped 窗口已重置(reset<=now)
// 或用量低于阈值 → 不停调(candidateMatchesThreshold 要求 until.After(now))。
func TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped(t *testing.T) {
@@ -451,6 +555,8 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) {
{"zhipu default", PlatformZhipu, "", "https://open.bigmodel.cn/api/paas/v4/models"},
{"zhipu coding", PlatformZhipu, AccountModeCoding, "https://open.bigmodel.cn/api/coding/paas/v4/models"},
{"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"},
{"minimax default", PlatformMiniMax, "", "https://api.minimaxi.com/v1/models"},
{"minimax coding", PlatformMiniMax, AccountModeCoding, "https://api.minimaxi.com/v1/models"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -487,7 +593,9 @@ func TestGetAPIProtocol(t *testing.T) {
require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol())
require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol())
require.Equal(t, APIProtocolResponses, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol())
require.Equal(t, APIProtocolResponses, mk(PlatformMiniMax, APIProtocolResponses).GetAPIProtocol())
require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol())
require.Equal(t, APIProtocolAdaptive, mk(PlatformMiniMax, APIProtocolAdaptive).GetAPIProtocol())
require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol())
require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol())
require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点")
@@ -500,6 +608,7 @@ func TestSupportsNativeCNResponses(t *testing.T) {
require.True(t, (&Account{Platform: PlatformDeepseek}).SupportsNativeCNResponses())
require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses())
require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses())
require.True(t, (&Account{Platform: PlatformMiniMax}).SupportsNativeCNResponses())
require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses())
require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses())
}
@@ -520,6 +629,8 @@ func TestAdaptiveProtocolBaseURLs(t *testing.T) {
{"zhipu payg", PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuPayGBaseURL},
{"zhipu coding", PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuCodingBaseURL},
{"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL},
{"minimax payg", PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL},
{"minimax coding", PlatformMiniMax, AccountModeCoding, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL},
}
for _, tc := range cases {
@@ -577,6 +688,10 @@ func TestAnthropicProtocolBaseURL(t *testing.T) {
Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_protocol": APIProtocolAnthropic},
}).GetAnthropicProtocolBaseURL())
require.Equal(t, "https://api.minimaxi.com/anthropic", (&Account{
Platform: PlatformMiniMax, Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_protocol": APIProtocolAnthropic},
}).GetAnthropicProtocolBaseURL())
// 凭证 base_url 覆盖默认值
require.Equal(t, "https://custom.example.com/anthropic", (&Account{
@@ -112,6 +112,8 @@ func DetectModelPlatform(model string) (string, bool) {
return PlatformZhipu, true
case "deepseek":
return PlatformDeepseek, true
case "minimax":
return PlatformMiniMax, true
}
if rest != "" {
normalized = strings.TrimPrefix(rest, "models/")
@@ -148,6 +150,11 @@ func DetectModelPlatform(model string) (string, bool) {
return PlatformZhipu, true
case strings.HasPrefix(normalized, "deepseek-"):
return PlatformDeepseek, true
case strings.HasPrefix(normalized, "minimax-"),
strings.HasPrefix(normalized, "abab5"),
strings.HasPrefix(normalized, "abab6"),
strings.HasPrefix(normalized, "abab7"):
return PlatformMiniMax, true
default:
return "", false
}
@@ -195,7 +202,7 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou
func isConcreteRequestPlatform(platform string) bool {
switch platform {
case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek:
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return true
default:
return false
@@ -176,6 +176,11 @@ func TestDetectModelPlatform(t *testing.T) {
{name: "moonshot prefix", model: "moonshot/moonshot-v1-32k", platform: PlatformKimi, ok: true},
{name: "zhipu", model: "glm-5.2", platform: PlatformZhipu, ok: true},
{name: "deepseek", model: "deepseek-v4-pro", platform: PlatformDeepseek, ok: true},
{name: "minimax", model: "MiniMax-M3", platform: PlatformMiniMax, ok: true},
{name: "minimax prefix", model: "minimax/MiniMax-M2.5", platform: PlatformMiniMax, ok: true},
{name: "abab legacy", model: "abab6.5-chat", platform: PlatformMiniMax, ok: true},
{name: "abab7 legacy", model: "abab7-chat-preview", platform: PlatformMiniMax, ok: true},
{name: "abab unrelated namespace", model: "abab-other", ok: false},
{name: "unknown k3 alias", model: "k3-preview", ok: false},
{name: "unknown", model: "llama-4-maverick", ok: false},
}
@@ -211,13 +216,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) {
platforms = append(platforms, platform)
}
require.ElementsMatch(t,
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek},
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax},
platforms,
)
}
func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
require.True(t, isConcreteRequestPlatform(platform))
require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform))
}
+10 -4
View File
@@ -47,6 +47,7 @@ const (
PlatformKimi = domain.PlatformKimi
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformComposite = domain.PlatformComposite
// PlatformKiro is retained for unsupported-platform threshold tests and legacy
// account rows. Scheduling-threshold evaluation never pauses kiro accounts.
@@ -75,6 +76,8 @@ const (
DefaultZhipuPayGBaseURL = "https://open.bigmodel.cn/api/paas/v4"
DefaultZhipuCodingBaseURL = "https://open.bigmodel.cn/api/coding/paas/v4"
DefaultDeepseekBaseURL = "https://api.deepseek.com"
// MiniMax 按量付费与 Coding/Token Plan 共用推理域名,靠 API Key 区分套餐。
DefaultMiniMaxBaseURL = "https://api.minimaxi.com/v1"
)
// 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。
@@ -84,12 +87,13 @@ const (
DefaultKimiCodingAnthropicBaseURL = "https://api.kimi.com/coding"
DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic"
DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic"
DefaultMiniMaxAnthropicBaseURL = "https://api.minimaxi.com/anthropic"
)
// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。
func IsCNProvider(platform string) bool {
switch platform {
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return true
default:
return false
@@ -108,17 +112,19 @@ var AllowedQuotaPlatforms = []string{
PlatformKimi,
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
}
// AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。
// openai/anthropic/grok 有原生用量窗口;kimi/zhipu 的 Coding Plan 同样暴露 5h/weekly
// 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。
// openai/anthropic/grok 有原生用量窗口;kimi/zhipu/minimax 的 Coding Plan 同样暴露
// 5h/weekly 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。
var AllowedSchedulingThresholdPlatforms = []string{
PlatformOpenAI,
PlatformAnthropic,
PlatformGrok,
PlatformKimi,
PlatformZhipu,
PlatformMiniMax,
}
// IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。
@@ -1014,7 +1014,7 @@ func OllamaCloudUsageStateFromAccount(account *Account) *OllamaCloudUsageState {
// (ollamaCloudUsagePlatformsSQL)是本列表的镜像,两侧必须同步修改。
func isOllamaCloudUsagePlatform(platform string) bool {
switch platform {
case PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return true
default:
return false
@@ -497,7 +497,7 @@ func TestIsOllamaCloudUsageAccountStrictOfficialHost(t *testing.T) {
// oauth 类型账号即使平台与 base_url 都命中也不进用量窗口(仅 apikey 账号)。
func TestIsOllamaCloudUsageAccountRejectsOAuthType(t *testing.T) {
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
account := ollamaUsageAccount(1)
account.Platform = platform
account.Type = AccountTypeOAuth
@@ -261,6 +261,7 @@ func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository,
PlatformKimi,
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
},
false,
)
@@ -1038,6 +1038,7 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
require.Contains(t, repo.platforms, PlatformOpenAI)
require.Contains(t, repo.platforms, PlatformGrok)
require.Contains(t, repo.platforms, PlatformDeepseek)
require.Contains(t, repo.platforms, PlatformMiniMax)
require.NotContains(t, repo.platforms, PlatformComposite)
}
@@ -24,7 +24,7 @@ import (
)
func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
g := &Group{Platform: platform}
require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"),
"CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform)
@@ -104,7 +104,7 @@ func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *te
}
func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := &Account{ID: 401, Platform: platform, Type: AccountTypeAPIKey}
@@ -292,7 +292,7 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount(
// handler 调度入口仍需导出,保持导出名。)
func NormalizeOpenAICompatiblePlatform(platform string) string {
switch platform {
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return platform
default:
return PlatformOpenAI
@@ -270,7 +270,7 @@ func TestSanitizeOpenAIResponsesToolSchemasForPlatform_ReplayBoundary(t *testing
// A malformed tool definition may be replayed after account failover. Every
// compatible account must repair it, while non-OpenAI providers retain their
// supported regex semantics.
for _, platform := range []string{PlatformAnthropic, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformAnthropic, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
t.Run(platform, func(t *testing.T) {
for attempt := 0; attempt < 2; attempt++ {
normalized, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, platform)
@@ -115,7 +115,7 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing.
}
func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
t.Run(platform, func(t *testing.T) {
cache := newBulkEventSnapshotCache()
repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}})
@@ -290,13 +290,14 @@ func TestSchedulerFullRebuildActiveTombstoneDoesNotBlockFollowingGroupEvent(t *t
require.Equal(t, 1, activeCalls)
require.Zero(t, fallbackCalls)
require.Equal(t, []int64{groupID, groupID}, freshCalls)
require.Len(t, cache.tokens(), 36, "full rebuild and the following group event must each run fresh authority")
canonicalCount := len(canonical)
require.Len(t, cache.tokens(), 2*canonicalCount, "full rebuild and the following group event must each run fresh authority")
_, reopenHeld := cache.lifecycleMutationLeaseStates()
require.Len(t, reopenHeld, 36)
require.Len(t, reopenHeld, 2*canonicalCount)
for _, held := range reopenHeld {
require.True(t, held)
}
require.Equal(t, 30, accounts.callCount())
require.Equal(t, 3*schedulerCanonicalAccountQueryCount(), accounts.callCount())
}
func TestSchedulerFullRebuildGlobalReadErrorsFailBeforeMutationOrDB(t *testing.T) {
@@ -367,15 +368,15 @@ func TestSchedulerFullRebuildFreshActivePreparesEveryTokenBeforeFirstDB(t *testi
capturesAtFirstDB = cache.captureAttemptCount()
held, reopenCount := cache.leaseHeldAndTokenCount()
require.False(t, held)
require.Equal(t, 18, reopenCount)
require.Equal(t, 19, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB")
require.Equal(t, schedulerCanonicalBucketCount(), reopenCount)
require.Equal(t, schedulerCanonicalBucketCount()+1, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB")
}
svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount())
require.Equal(t, 21, accounts.callCount())
require.Equal(t, 11, accounts.groupCallCount(groupID))
require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+1, accounts.callCount())
require.Equal(t, schedulerCanonicalAccountQueryCount()+1, accounts.groupCallCount(groupID))
_, historicalPublished := cache.counts(historical)
require.Equal(t, 1, historicalPublished)
activeCalls, fallbackCalls, freshCalls := groups.stats()
@@ -398,7 +399,7 @@ func TestSchedulerFullRebuildOrdinaryCaptureErrorReturnsBeforeFirstDB(t *testing
err := svc.rebuildFullSnapshot(context.Background(), "test")
require.ErrorIs(t, err, wantErr)
require.Equal(t, 20, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning")
require.Equal(t, schedulerCanonicalBucketCount()+2, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning")
require.Zero(t, accounts.callCount())
require.Zero(t, cache.totalSetAttempts())
}
@@ -416,8 +417,8 @@ func TestSchedulerFullRebuildPreservesGroupZeroActiveHistoricalAndInvalidRegistr
svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
require.Equal(t, 39, cache.captureAttemptCount())
require.Equal(t, 23, accounts.callCount())
require.Equal(t, 2*schedulerCanonicalBucketCount()+3, cache.captureAttemptCount())
require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+3, accounts.callCount())
groups.mu.Lock()
require.Equal(t, 1, groups.listCalls)
groups.mu.Unlock()
@@ -456,7 +457,7 @@ func TestSchedulerFullRebuildActiveTombstoneFreshInactiveOrMissingFiltersAllGrou
require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
require.Zero(t, accounts.groupCallCount(groupID))
require.Equal(t, 10, accounts.groupCallCount(0))
require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.groupCallCount(0))
require.Empty(t, cache.tokens())
require.Equal(t, bucketStrings(append(canonical, historical)), bucketStrings(cache.retiredBuckets()))
for _, bucket := range append(canonical, historical) {
@@ -528,7 +529,7 @@ func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t
require.Equal(t, []int64{1, 2}, freshCalls)
require.Zero(t, accounts.callCount())
require.Zero(t, cache.totalSetAttempts())
require.Equal(t, 19, len(cache.retiredBuckets()))
require.Equal(t, schedulerCanonicalBucketCount()+1, len(cache.retiredBuckets()))
groups.mu.Lock()
delete(groups.freshErr, 2)
@@ -537,8 +538,8 @@ func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t
require.NoError(t, svc.triggerFullRebuild("retry"))
_, _, freshCalls = groups.stats()
require.Equal(t, []int64{1, 2, 2, 3}, freshCalls)
require.Equal(t, 57, len(cache.retiredBuckets()))
require.Equal(t, 10, accounts.callCount())
require.Equal(t, 3*(schedulerCanonicalBucketCount()+1), len(cache.retiredBuckets()))
require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
require.Empty(t, cache.tokens())
}
@@ -561,14 +562,15 @@ func TestSchedulerFullRebuildActiveTombstoneLazyRecoveryDiscardsPartialCaptureTa
capturesAtFirstDB = cache.captureAttemptCount()
held, reopenCount := cache.leaseHeldAndTokenCount()
require.False(t, held)
require.Equal(t, 18, reopenCount)
require.Equal(t, 25, capturesAtFirstDB)
require.Equal(t, len(canonical), reopenCount)
// C(0) + captures through the retired canonical index (inclusive) + historical ordinary bucket.
require.Equal(t, schedulerCanonicalBucketCount()+6+1, capturesAtFirstDB)
}
svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount())
require.Equal(t, 21, accounts.callCount())
require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+1, accounts.callCount())
for _, bucket := range canonical {
attempts, published := cache.counts(bucket)
require.Equal(t, 1, attempts, "discarded pre-recovery tokens must never publish: %s", bucket.String())
@@ -600,9 +602,9 @@ func TestSchedulerFullRebuildSimpleModePreservesRegistryWithoutLifecycleAuthorit
require.Zero(t, activeCalls)
require.Zero(t, fallbackCalls)
require.Empty(t, freshCalls)
require.Equal(t, 21, cache.captureAttemptCount())
require.Equal(t, 13, accounts.callCount())
require.Equal(t, 13, accounts.groupCallCount(0))
require.Equal(t, schedulerCanonicalBucketCount()+len(registered), cache.captureAttemptCount())
require.Equal(t, schedulerCanonicalAccountQueryCount()+len(registered), accounts.callCount())
require.Equal(t, schedulerCanonicalAccountQueryCount()+len(registered), accounts.groupCallCount(0))
require.Empty(t, cache.retiredBuckets())
require.Empty(t, cache.tokens())
for _, bucket := range registered {
@@ -632,11 +634,11 @@ func TestSchedulerFullRebuildFreshReopenLockBusyRetriesWithoutBlockingOrdinaryTa
require.Zero(t, cache.currentWatermark())
_, groupZeroPublished := cache.counts(schedulerCanonicalBuckets(0)[0])
require.Equal(t, 1, groupZeroPublished, "ordinary tasks must still run when one strict Reopen task is busy")
require.Equal(t, 20, accounts.callCount())
require.Equal(t, 2*schedulerCanonicalAccountQueryCount(), accounts.callCount())
svc.pollOutbox()
require.Equal(t, int64(1), cache.currentWatermark())
require.Equal(t, 40, accounts.callCount())
require.Equal(t, 4*schedulerCanonicalAccountQueryCount(), accounts.callCount())
_, busyBucketPublished := cache.counts(canonical[0])
require.Equal(t, 1, busyBucketPublished)
activeCalls, fallbackCalls, freshCalls := groups.stats()
@@ -654,7 +656,7 @@ func TestSchedulerFullRebuildOrdinaryLockBusyKeepsExistingSkipSemantics(t *testi
svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
require.Equal(t, 10, accounts.callCount())
require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
attempts, published := cache.counts(busyBucket)
require.Zero(t, attempts)
require.Zero(t, published)
@@ -325,18 +325,18 @@ func newGroupLifecycleTestService(cache SchedulerCache, accounts AccountReposito
}
func expectedGroupLifecycleBuckets(groupID int64) []SchedulerBucket {
platforms := schedulerSnapshotPlatforms()
buckets := make([]SchedulerBucket, 0, 18)
for _, platform := range platforms {
buckets = append(buckets,
SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle},
SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeForced},
)
return schedulerCanonicalBuckets(groupID)
}
func schedulerCanonicalAccountQueryCount() int {
count := 0
for _, platform := range schedulerSnapshotPlatforms() {
count++
if platform == PlatformAnthropic || platform == PlatformGemini {
buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeMixed})
count++
}
}
return buckets
return count
}
func bucketStrings(buckets []SchedulerBucket) map[string]struct{} {
@@ -441,7 +441,7 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes
accounts.beforeLoad = func() {
held, tokenCount := cache.leaseHeldAndTokenCount()
require.False(t, held, "the group lifecycle lease must be released before the first account query")
require.Equal(t, 18, tokenCount, "all reopen tokens must be prepared before the first account query")
require.Equal(t, len(current), tokenCount, "all reopen tokens must be prepared before the first account query")
}
svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
seen := make(map[batchSeenKey]struct{})
@@ -453,8 +453,8 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes
registered, err := cache.retirementRaceCache.ListBuckets(context.Background())
require.NoError(t, err)
require.Contains(t, bucketStrings(registered), historical.String())
require.Len(t, cache.tokens(), 18)
require.Equal(t, 10, accounts.callCount())
require.Len(t, cache.tokens(), len(current))
require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
require.Equal(t, 1, accounts.platformCallCount(PlatformOpenAI))
for _, bucket := range current {
_, published := cache.counts(bucket)
@@ -472,16 +472,16 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes
require.True(t, cache.releaseDeadline)
require.NoError(t, cache.releaseCtxErr)
_, reopenHeld := cache.lifecycleMutationLeaseStates()
require.Len(t, reopenHeld, 18)
require.Len(t, reopenHeld, len(current))
for _, held := range reopenHeld {
require.True(t, held)
}
lockTTLs, unlockCalls := cache.lockStats()
require.Len(t, lockTTLs, 18)
require.Len(t, lockTTLs, len(current))
for _, ttl := range lockTTLs {
require.Equal(t, 30*time.Second, ttl)
}
require.Equal(t, 18, unlockCalls)
require.Equal(t, len(current), unlockCalls)
requireLifecycleSeen(t, seen, groupID)
}
@@ -497,8 +497,8 @@ func TestSchedulerGroupLifecycleInactiveThenActiveAuthoritativelyReopens(t *test
groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil)
require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
require.Len(t, cache.tokens(), 18)
require.Equal(t, 10, accounts.callCount())
require.Len(t, cache.tokens(), schedulerCanonicalBucketCount())
require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
for _, bucket := range expectedGroupLifecycleBuckets(groupID) {
_, published := cache.counts(bucket)
require.Equal(t, 1, published, bucket.String())
@@ -546,15 +546,16 @@ func TestSchedulerGroupLifecycleEpochPreventsABA(t *testing.T) {
groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil)
require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
firstActiveTokens := cache.tokens()
require.Len(t, firstActiveTokens, 18)
canonicalCount := schedulerCanonicalBucketCount()
require.Len(t, firstActiveTokens, canonicalCount)
groups.set(&Group{ID: groupID, Status: StatusDisabled, Hydrated: true}, nil)
require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil)
require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
allTokens := cache.tokens()
require.Len(t, allTokens, 36)
require.Greater(t, allTokens[18].Epoch, firstActiveTokens[0].Epoch)
require.Len(t, allTokens, 2*canonicalCount)
require.Greater(t, allTokens[canonicalCount].Epoch, firstActiveTokens[0].Epoch)
require.ErrorIs(t, cache.SetSnapshot(context.Background(), firstActiveTokens[0].Bucket, firstActiveTokens[0], nil), ErrSchedulerBucketWriteFenced)
}
@@ -571,11 +572,11 @@ func TestSchedulerGroupLifecycleSeenIsIndependentAndDeduplicatesGroupEvents(t *t
require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen))
require.Equal(t, 1, groups.callCount())
require.Equal(t, 10, accounts.callCount())
require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
requireLifecycleSeen(t, seen, groupID)
require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen))
require.Equal(t, 1, groups.callCount())
require.Equal(t, 10, accounts.callCount())
require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
}
func TestSchedulerGroupLifecycleFailuresDoNotMarkSeen(t *testing.T) {
@@ -176,7 +176,7 @@ func TestSchedulerFullRebuildCapturesAllRegistryTokensBeforeDBLoad(t *testing.T)
}
captures, reopens := cache.captureAndReopenCounts()
require.Equal(t, 36, captures, "group0 and active-group canonical tokens must be captured before the first DB load")
require.Equal(t, 2*schedulerCanonicalBucketCount(), captures, "group0 and active-group canonical tokens must be captured before the first DB load")
require.Zero(t, reopens)
require.NoError(t, cache.RetireBucket(context.Background(), queued))
_, err := cache.ReopenBucket(context.Background(), queued)
@@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p
}
accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs)
switch account.Platform {
case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
addPlatformGroups(account.Platform, accountGroupIDs)
case PlatformAntigravity:
// 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。
@@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account
return s.rebuildBuckets(ctx, buckets, reason)
}
func schedulerSnapshotPlatforms() [8]string {
return [8]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}
func schedulerSnapshotPlatforms() [9]string {
return [9]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
}
// 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。
@@ -836,9 +836,21 @@ func schedulerBucketsForGroup(groupID int64) []SchedulerBucket {
return schedulerCanonicalBuckets(groupID)
}
func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket {
buckets := make([]SchedulerBucket, 0, 18)
func schedulerCanonicalBucketCount() int {
count := 0
for _, platform := range schedulerSnapshotPlatforms() {
count += 2
if platform == PlatformAnthropic || platform == PlatformGemini {
count++
}
}
return count
}
func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket {
platforms := schedulerSnapshotPlatforms()
buckets := make([]SchedulerBucket, 0, len(platforms)*2+2)
for _, platform := range platforms {
buckets = append(buckets,
SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle},
SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeForced},
@@ -855,7 +867,7 @@ func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupI
if len(groupIDs) == 0 {
return nil
}
buckets := make([]SchedulerBucket, 0, len(groupIDs)*18)
buckets := make([]SchedulerBucket, 0, len(groupIDs)*schedulerCanonicalBucketCount())
for _, platform := range schedulerSnapshotPlatforms() {
buckets = append(buckets, s.bucketsForPlatform(platform, groupIDs, seen)...)
}
@@ -1013,7 +1013,7 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool {
}
switch platform {
case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek:
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return true
default:
return false
@@ -16,7 +16,7 @@ import (
func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) {
for _, platform := range []string{
PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity,
PlatformKimi, PlatformZhipu, PlatformDeepseek,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax,
} {
require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform)
require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform)
@@ -0,0 +1,60 @@
-- 把 MiniMax 加入国产供应商平台白名单:
-- 1. user_platform_quotas.platform CHECK
-- 2. composite_model_routes.target_platform CHECK
-- 3. channel_monitors / channel_monitor_request_templates.provider CHECK
--
-- 与 224/226/227 同型:DROP IF EXISTS 后重建超集约束,存量行瞬时校验通过。
ALTER TABLE user_platform_quotas
DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check;
ALTER TABLE user_platform_quotas
ADD CONSTRAINT user_platform_quotas_platform_check
CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax'));
ALTER TABLE composite_model_routes
DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check;
ALTER TABLE composite_model_routes
ADD CONSTRAINT composite_model_routes_target_platform_check
CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax'));
DO $$
DECLARE
monitor_constraint_def TEXT;
template_constraint_def TEXT;
BEGIN
SELECT pg_get_constraintdef(c.oid)
INTO monitor_constraint_def
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'channel_monitors'
AND c.conname = 'channel_monitors_provider_check';
IF monitor_constraint_def IS NULL OR position('minimax' IN monitor_constraint_def) = 0 THEN
ALTER TABLE channel_monitors
DROP CONSTRAINT IF EXISTS channel_monitors_provider_check;
ALTER TABLE channel_monitors
ADD CONSTRAINT channel_monitors_provider_check
CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok',
'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax'));
END IF;
SELECT pg_get_constraintdef(c.oid)
INTO template_constraint_def
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'channel_monitor_request_templates'
AND c.conname = 'channel_monitor_request_templates_provider_check';
IF template_constraint_def IS NULL OR position('minimax' IN template_constraint_def) = 0 THEN
ALTER TABLE channel_monitor_request_templates
DROP CONSTRAINT IF EXISTS channel_monitor_request_templates_provider_check;
ALTER TABLE channel_monitor_request_templates
ADD CONSTRAINT channel_monitor_request_templates_provider_check
CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok',
'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax'));
END IF;
END $$;
@@ -0,0 +1,21 @@
package migrations
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMiniMaxPlatformMigration(t *testing.T) {
content, err := FS.ReadFile("237_add_minimax_platform.sql")
require.NoError(t, err)
sql := strings.Join(strings.Fields(string(content)), " ")
require.Contains(t, sql,
"CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax'))")
require.Contains(t, sql,
"CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax'))")
require.Contains(t, sql,
"CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', 'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax'))")
}
+8
View File
@@ -14,6 +14,10 @@ Composite groups can route to these concrete account platforms:
- OpenAI
- Antigravity
- Grok
- Kimi
- Zhipu GLM
- DeepSeek
- MiniMax
The selected concrete platform is used for account selection, user platform
quota checks, post-usage billing, ops error platform attribution, channel
@@ -68,6 +72,10 @@ Composite routing detects common public model IDs and provider-prefixed IDs:
- `gpt-*`, `o*`, `codex-*`, `text-embedding-*`, `dall-e-*`, and
`openai/*` route to OpenAI.
- `grok-*` and `xai/grok-*` route to Grok.
- `kimi-*`, `moonshot-*`, and `k3` route to Kimi.
- `glm-*` routes to Zhipu GLM.
- `deepseek-*` routes to DeepSeek.
- `minimax-*`, `abab*`, and `minimax/*` route to MiniMax.
Unknown or ambiguous model names fail closed with a client error instead of
guessing a provider.
+1
View File
@@ -14,6 +14,7 @@ export type Provider =
| 'kimi'
| 'zhipu'
| 'deepseek'
| 'minimax'
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
export type APIMode = 'chat_completions' | 'responses'
+3 -1
View File
@@ -38,17 +38,19 @@ export type SchedulingThresholdPlatformType =
| "grok"
| "kimi"
| "zhipu"
| "minimax"
export type AccountSchedulingThresholdsMap = Record<SchedulingThresholdPlatformType, number>
// 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型,
// 走余额检测而非用量阈值)。
// 走余额检测而非用量阈值;minimax Coding/Token Plan 有 5h/weekly 窗口)。
export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [
"openai",
"anthropic",
"grok",
"kimi",
"zhipu",
"minimax",
]
export function normalizeAccountSchedulingThresholdsMap(
@@ -430,7 +430,7 @@
</template>
<!-- CN providers (Kimi / Zhipu / DeepSeek): coding-plan quota or payg balance -->
<template v-else-if="account.platform === 'kimi' || account.platform === 'zhipu' || account.platform === 'deepseek'">
<template v-else-if="account.platform === 'kimi' || account.platform === 'zhipu' || account.platform === 'deepseek' || account.platform === 'minimax'">
<!-- 挂在 CN 平台下的 Ollama Cloud 账号(资格由后端下发 eligible):用量由
Ollama 用量窗口负责。这类账号不是国产厂商订阅,CN 的额度/余额探测端点由
base_url 衍生,对 ollama.com 会被后端出站 URL 白名单拒绝,渲染出来只会
@@ -727,7 +727,8 @@ const showUsageWindows = computed(() => {
if (
props.account.platform === 'kimi' ||
props.account.platform === 'zhipu' ||
props.account.platform === 'deepseek'
props.account.platform === 'deepseek' ||
props.account.platform === 'minimax'
) {
return true
}
@@ -26,7 +26,7 @@ import { CN_BASE_URL_PRESETS, type CnBaseUrlPreset } from './credentialsBuilder'
// 与 Grok 预设一致,仅作快速填充,输入框仍接受任意第三方转发地址。
// 传入 protocol 时只显示该协议档的预设(协议 × 账号类型正交分档)。
const props = defineProps<{
platform: 'kimi' | 'zhipu' | 'deepseek'
platform: 'kimi' | 'zhipu' | 'deepseek' | 'minimax'
/** 当前已选账号类型,用于过滤和高亮匹配的预设 */
mode?: 'payg' | 'coding'
/** 当前已选 API 协议,用于过滤和高亮匹配的预设 */
@@ -202,6 +202,19 @@
<PlatformIcon platform="deepseek" size="sm" />
DeepSeek
</button>
<button
type="button"
@click="selectCNPlatform('minimax')"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'minimax'
? 'bg-white text-rose-600 shadow-sm dark:bg-dark-600 dark:text-rose-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<PlatformIcon platform="minimax" size="sm" />
MiniMax
</button>
</div>
</div>
@@ -3862,11 +3875,13 @@ import {
cnSupportsNativeResponses,
defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
isCNProviderPlatform,
isHeaderOverrideCapable,
validateHeaderOverrideRows,
type CnAccountMode,
type CnApiProtocol,
type CnNativeApiProtocol,
type CnProviderPlatform,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import {
@@ -3969,6 +3984,8 @@ const apiKeyValuePlaceholder = computed(() => {
return '<api-key>.<secret>'
case 'deepseek':
return 'sk-...'
case 'minimax':
return 'sk-...'
default:
return 'sk-ant-...'
}
@@ -4070,13 +4087,11 @@ const adaptiveBaseUrls = ref<Record<CnNativeApiProtocol, string>>({
anthropic: '',
responses: ''
})
const isCNPlatform = computed(
() => form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek'
)
const isCNPlatform = computed(() => isCNProviderPlatform(form.platform))
// CnBaseUrlPresets 的 platform prop 是平台字面量联合类型,模板里不能写
// `as` 断言(其中的 `|` 会被 eslint 误判为 Vue2 filter 语法),经此 computed 传递。
const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
if (form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek') {
const cnPresetPlatform = computed<CnProviderPlatform>(() => {
if (isCNProviderPlatform(form.platform)) {
return form.platform
}
return 'kimi'
@@ -4102,7 +4117,7 @@ const cnAdaptiveProtocolOptions = computed<Array<{ value: CnNativeApiProtocol; l
return opts
})
function resetAdaptiveBaseUrls(platform: 'kimi' | 'zhipu' | 'deepseek', mode: CnAccountMode) {
function resetAdaptiveBaseUrls(platform: CnProviderPlatform, mode: CnAccountMode) {
adaptiveBaseUrls.value = defaultCNAdaptiveBaseUrls(platform, mode)
}
// 当前选中平台的品牌色(选中卡片描边 / 图标底色),与 platformColors 取色一致。
@@ -4114,6 +4129,8 @@ const cnAccentActiveClass = computed(() => {
return 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
case 'deepseek':
return 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
case 'minimax':
return 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
default:
return 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
}
@@ -4126,13 +4143,15 @@ const cnAccentIconClass = computed(() => {
return 'bg-indigo-500 text-white'
case 'deepseek':
return 'bg-teal-500 text-white'
case 'minimax':
return 'bg-rose-500 text-white'
default:
return 'bg-primary-500 text-white'
}
})
// 切换国产供应商平台:强制 apikey 类型,deepseek 无 coding 套餐故锁定 payg,
// 协议回落 adaptive,并把 base url 重置为该平台默认端点。
function selectCNPlatform(platform: 'kimi' | 'zhipu' | 'deepseek') {
function selectCNPlatform(platform: CnProviderPlatform) {
form.platform = platform
form.type = 'apikey'
accountCategory.value = 'apikey'
@@ -4685,7 +4704,7 @@ watch(
() => form.platform,
(newPlatform) => {
// Reset base URL based on platform
if (newPlatform === 'kimi' || newPlatform === 'zhipu' || newPlatform === 'deepseek') {
if (isCNProviderPlatform(newPlatform)) {
apiKeyBaseUrl.value = defaultCNBaseUrl(newPlatform, accountMode.value, apiProtocol.value)
} else {
apiKeyBaseUrl.value =
@@ -5635,7 +5654,7 @@ const handleSubmit = async () => {
// 国产供应商:账号模式 + 协议 + 对应端点写入凭据;后端按 account_mode 路由
// 额度/余额探测,按 api_protocol 路由转发端点与格式。注意 CN apikey 走本函数
// 的通用路径(直接 doCreateAccount),不经过 createAccountAndFinish。
if (form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek') {
if (isCNProviderPlatform(form.platform)) {
credentials.account_mode = accountMode.value
credentials.api_protocol = apiProtocol.value
if (apiProtocol.value === 'adaptive') {
@@ -2957,11 +2957,13 @@ import {
cnSupportsNativeResponses,
defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
isCNProviderPlatform,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
type CnAccountMode,
type CnApiProtocol,
type CnNativeApiProtocol,
type CnProviderPlatform,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import {
@@ -3055,18 +3057,14 @@ const editApiKey = ref('')
// account_mode 决定额度/余额监控路径,api_protocol 决定转发端点与格式;
// 二者均可修正(早期创建的账号可能存错默认值),切换时重置 base_url 预置。
const isCNApiKeyAccount = computed(
() =>
props.account?.type === 'apikey' &&
(props.account.platform === 'kimi' ||
props.account.platform === 'zhipu' ||
props.account.platform === 'deepseek')
() => props.account?.type === 'apikey' && isCNProviderPlatform(props.account.platform)
)
// CnBaseUrlPresets 的 platform prop 是平台字面量联合类型,模板里不能写
// `as` 断言(其中的 `|` 会被 eslint 误判为 Vue2 filter 语法),经此 computed 传递。
const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
const cnPresetPlatform = computed<CnProviderPlatform>(() => {
const platform = props.account?.platform
if (platform === 'kimi' || platform === 'zhipu' || platform === 'deepseek') {
return platform
if (isCNProviderPlatform(platform ?? '')) {
return platform as CnProviderPlatform
}
return 'kimi'
})
@@ -3993,7 +3991,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
const credentials = newAccount.credentials as Record<string, unknown>
// 国产供应商:读取 account_mode 与 api_protocol 作为可编辑初始值
// (编辑弹窗允许修正两者,用于修复早期存错默认值的账号)。
if (newAccount.platform === 'kimi' || newAccount.platform === 'zhipu' || newAccount.platform === 'deepseek') {
if (isCNProviderPlatform(newAccount.platform)) {
editAccountMode.value = credentials.account_mode === 'coding' ? 'coding' : 'payg'
const storedProtocol = credentials.api_protocol
editApiProtocol.value =
@@ -208,7 +208,8 @@ const upstreamSyncPlatforms = new Set([
'grok',
'kimi',
'zhipu',
'deepseek'
'deepseek',
'minimax'
])
const canSyncUpstream = computed(() => {
if (props.accountId) {
@@ -154,7 +154,7 @@ describe('AccountUsageCell', () => {
expect(updatedAccount?.ollama_cloud_usage?.auto_refresh_enabled).toBe(false)
})
it.each(['kimi', 'zhipu', 'deepseek'] as const)(
it.each(['kimi', 'zhipu', 'deepseek', 'minimax'] as const)(
'%s apikey 账号 Ollama Cloud eligible 时渲染 Ollama 用量单元格并跳过 CN 子单元格',
async (platform) => {
const wrapper = mount(AccountUsageCell, {
@@ -217,7 +217,7 @@ describe('BulkEditAccountModal', () => {
expect(wrapper.findAll('[data-testid="grok-base-url-preset"]').length).toBe(0)
})
it.each(['kimi', 'zhipu', 'deepseek'])('全部目标为 %s API Key 时展示请求头覆写', (platform) => {
it.each(['kimi', 'zhipu', 'deepseek', 'minimax'])('全部目标为 %s API Key 时展示请求头覆写', (platform) => {
const wrapper = mountModal({
selectedPlatforms: [platform],
selectedTypes: ['apikey']
@@ -226,7 +226,7 @@ describe('BulkEditAccountModal', () => {
expect(wrapper.find('#bulk-edit-header-override-enabled').exists()).toBe(true)
})
it.each(['kimi', 'zhipu', 'deepseek'])('目标为 %s OAuth 时不展示请求头覆写', (platform) => {
it.each(['kimi', 'zhipu', 'deepseek', 'minimax'])('目标为 %s OAuth 时不展示请求头覆写', (platform) => {
const wrapper = mountModal({
selectedPlatforms: [platform],
selectedTypes: ['oauth']
@@ -490,6 +490,28 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
})
})
it('submits adaptive MiniMax protocol endpoints', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'MiniMax')
await wrapper.get('form#create-account-form input[type="text"]').setValue('MiniMax adaptive')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-minimax')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'payg',
api_protocol: 'adaptive',
base_url: 'https://api.minimaxi.com/v1',
api_base_urls: {
chat_completions: 'https://api.minimaxi.com/v1',
anthropic: 'https://api.minimaxi.com/anthropic',
responses: 'https://api.minimaxi.com/v1'
}
})
})
it('uses the edited adaptive Chat endpoint when previewing upstream models', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Kimi')
@@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest'
import { cnSupportsNativeResponses, defaultCNAdaptiveBaseUrls } from '../credentialsBuilder'
describe('cnSupportsNativeResponses', () => {
it('is true for DeepSeek and Kimi only', () => {
it('is true for DeepSeek, Kimi, and MiniMax', () => {
expect(cnSupportsNativeResponses('deepseek')).toBe(true)
expect(cnSupportsNativeResponses('kimi')).toBe(true)
expect(cnSupportsNativeResponses('minimax')).toBe(true)
expect(cnSupportsNativeResponses('zhipu')).toBe(false)
expect(cnSupportsNativeResponses('openai')).toBe(false)
})
@@ -45,4 +46,14 @@ describe('defaultCNAdaptiveBaseUrls', () => {
responses: 'https://api.deepseek.com'
})
})
it('uses the same MiniMax CN endpoints for payg and coding', () => {
const expected = {
chat_completions: 'https://api.minimaxi.com/v1',
anthropic: 'https://api.minimaxi.com/anthropic',
responses: 'https://api.minimaxi.com/v1'
}
expect(defaultCNAdaptiveBaseUrls('minimax', 'payg')).toEqual(expected)
expect(defaultCNAdaptiveBaseUrls('minimax', 'coding')).toEqual(expected)
})
})
@@ -107,7 +107,7 @@ describe('isHeaderOverrideCapable', () => {
})
it('kimi/zhipu/deepseek only support apikey accounts', () => {
for (const platform of ['kimi', 'zhipu', 'deepseek']) {
for (const platform of ['kimi', 'zhipu', 'deepseek', 'minimax']) {
expect(isHeaderOverrideCapable(platform, 'apikey')).toBe(true)
expect(isHeaderOverrideCapable(platform, 'oauth')).toBe(false)
}
@@ -42,7 +42,8 @@ export function isHeaderOverrideCapable(platform: string, type: string): boolean
platform === 'openai' ||
platform === 'kimi' ||
platform === 'zhipu' ||
platform === 'deepseek'
platform === 'deepseek' ||
platform === 'minimax'
) {
return type === 'apikey'
}
@@ -255,14 +256,19 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [
// 两者正交。同协议请求零转换直通,跨协议组合才走转换链。
export type CnAccountMode = 'payg' | 'coding'
export type CnProviderPlatform = 'kimi' | 'zhipu' | 'deepseek' | 'minimax'
/** deepseek / kimi 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
/** deepseek / kimi / minimax 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
export type CnApiProtocol = 'adaptive' | 'chat_completions' | 'anthropic' | 'responses'
export type CnNativeApiProtocol = Exclude<CnApiProtocol, 'adaptive'>
/** DeepSeek 与 Kimi(按量付费 / Coding Plan)提供原生 Responses 端点。 */
export function isCNProviderPlatform(platform: string): platform is CnProviderPlatform {
return platform === 'kimi' || platform === 'zhipu' || platform === 'deepseek' || platform === 'minimax'
}
/** DeepSeek、Kimi 与 MiniMax 提供原生 Responses 端点。 */
export function cnSupportsNativeResponses(platform: string): boolean {
return platform === 'deepseek' || platform === 'kimi'
return platform === 'deepseek' || platform === 'kimi' || platform === 'minimax'
}
export interface CnBaseUrlPreset {
@@ -274,7 +280,7 @@ export interface CnBaseUrlPreset {
}
/** 各供应商按账号类型 × API 协议分档的快捷端点(点击快速填充,输入框仍可自由填写)。 */
export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUrlPreset[]> = {
export const CN_BASE_URL_PRESETS: Record<CnProviderPlatform, CnBaseUrlPreset[]> = {
kimi: [
{ mode: 'payg', protocol: 'chat_completions', label: 'Moonshot', url: 'https://api.moonshot.cn/v1' },
{ mode: 'payg', protocol: 'anthropic', label: 'Moonshot Anthropic', url: 'https://api.moonshot.cn/anthropic' },
@@ -293,6 +299,20 @@ export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUr
{ mode: 'payg', protocol: 'chat_completions', label: 'DeepSeek', url: 'https://api.deepseek.com' },
{ mode: 'payg', protocol: 'anthropic', label: 'DeepSeek Anthropic', url: 'https://api.deepseek.com/anthropic' },
{ mode: 'payg', protocol: 'responses', label: 'DeepSeek Responses', url: 'https://api.deepseek.com' }
],
minimax: [
{ mode: 'payg', protocol: 'chat_completions', label: 'MiniMax CN', url: 'https://api.minimaxi.com/v1' },
{ mode: 'payg', protocol: 'anthropic', label: 'MiniMax CN Anthropic', url: 'https://api.minimaxi.com/anthropic' },
{ mode: 'payg', protocol: 'responses', label: 'MiniMax CN Responses', url: 'https://api.minimaxi.com/v1' },
{ mode: 'payg', protocol: 'chat_completions', label: 'MiniMax Intl', url: 'https://api.minimax.io/v1' },
{ mode: 'payg', protocol: 'anthropic', label: 'MiniMax Intl Anthropic', url: 'https://api.minimax.io/anthropic' },
{ mode: 'payg', protocol: 'responses', label: 'MiniMax Intl Responses', url: 'https://api.minimax.io/v1' },
{ mode: 'coding', protocol: 'chat_completions', label: 'MiniMax Coding CN', url: 'https://api.minimaxi.com/v1' },
{ mode: 'coding', protocol: 'anthropic', label: 'MiniMax Coding CN Anthropic', url: 'https://api.minimaxi.com/anthropic' },
{ mode: 'coding', protocol: 'responses', label: 'MiniMax Coding CN Responses', url: 'https://api.minimaxi.com/v1' },
{ mode: 'coding', protocol: 'chat_completions', label: 'MiniMax Coding Intl', url: 'https://api.minimax.io/v1' },
{ mode: 'coding', protocol: 'anthropic', label: 'MiniMax Coding Intl Anthropic', url: 'https://api.minimax.io/anthropic' },
{ mode: 'coding', protocol: 'responses', label: 'MiniMax Coding Intl Responses', url: 'https://api.minimax.io/v1' }
]
}
@@ -310,11 +330,13 @@ export function defaultCNBaseUrl(
return 'https://open.bigmodel.cn/api/anthropic'
case 'deepseek':
return 'https://api.deepseek.com/anthropic'
case 'minimax':
return 'https://api.minimaxi.com/anthropic'
default:
return ''
}
}
// responses:Kimi / DeepSeek 的 base 与 chat_completions 相同(端点路径差异由后端处理)。
// responses:Kimi / DeepSeek / MiniMax 的 base 与 chat_completions 相同(端点路径差异由后端处理)。
switch (platform) {
case 'kimi':
return mode === 'coding' ? 'https://api.kimi.com/coding/v1' : 'https://api.moonshot.cn/v1'
@@ -324,6 +346,8 @@ export function defaultCNBaseUrl(
: 'https://open.bigmodel.cn/api/paas/v4'
case 'deepseek':
return 'https://api.deepseek.com'
case 'minimax':
return 'https://api.minimaxi.com/v1'
default:
return ''
}
@@ -331,7 +355,7 @@ export function defaultCNBaseUrl(
/** 返回自适应模式下需要配置的原生协议及其默认端点。 */
export function defaultCNAdaptiveBaseUrls(
platform: 'kimi' | 'zhipu' | 'deepseek',
platform: CnProviderPlatform,
mode: CnAccountMode
): Record<CnNativeApiProtocol, string> {
return {
@@ -346,7 +370,7 @@ export function defaultCNAdaptiveBaseUrls(
// 共用,避免多处复制条件后一处改另一处漏改。
export function cnQuotaCellVisible(platform: string, accountMode: string): boolean {
return (platform === 'kimi' || platform === 'zhipu') && accountMode === 'coding'
return (platform === 'kimi' || platform === 'zhipu' || platform === 'minimax') && accountMode === 'coding'
}
export function cnBalanceCellVisible(platform: string, accountMode: string): boolean {
@@ -75,6 +75,7 @@ import {
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDER_MINIMAX,
} from '@/constants/channelMonitor'
defineProps<{
@@ -104,6 +105,7 @@ const providerFilterOptions = computed(() => [
{ value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') },
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
])
const enabledFilterOptions = computed(() => [
@@ -269,6 +269,7 @@ import {
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDER_MINIMAX,
API_MODE_CHAT_COMPLETIONS,
API_MODE_RESPONSES,
CHECK_MODE_PROBE,
@@ -279,6 +280,7 @@ import {
DEFAULT_KIMI_ENDPOINT,
DEFAULT_ZHIPU_ENDPOINT,
DEFAULT_DEEPSEEK_ENDPOINT,
DEFAULT_MINIMAX_ENDPOINT,
DEFAULT_INTERVAL_SECONDS,
} from '@/constants/channelMonitor'
@@ -473,6 +475,7 @@ const providerOptions = computed<ProviderOption[]>(() => [
{ value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') },
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
])
// 国产 provider 预填的官方 endpoint(仅探活侧;配额模式 endpoint 可留空)。
@@ -480,6 +483,7 @@ const PROVIDER_DEFAULT_ENDPOINTS: Partial<Record<Provider, string>> = {
[PROVIDER_KIMI]: DEFAULT_KIMI_ENDPOINT,
[PROVIDER_ZHIPU]: DEFAULT_ZHIPU_ENDPOINT,
[PROVIDER_DEEPSEEK]: DEFAULT_DEEPSEEK_ENDPOINT,
[PROVIDER_MINIMAX]: DEFAULT_MINIMAX_ENDPOINT,
}
interface CheckModeOption {
@@ -253,6 +253,7 @@ import {
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDER_MINIMAX,
PROVIDERS,
API_MODE_CHAT_COMPLETIONS,
API_MODE_RESPONSES,
@@ -278,6 +279,7 @@ const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
{ value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') },
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
])
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
@@ -171,6 +171,9 @@ const labelClass = computed(() => {
if (props.platform === 'deepseek') {
return `${base} bg-teal-200/60 text-teal-800 dark:bg-teal-800/40 dark:text-teal-300`
}
if (props.platform === 'minimax') {
return `${base} bg-rose-200/60 text-rose-800 dark:bg-rose-800/40 dark:text-rose-300`
}
if (props.platform === 'composite') {
return `${base} bg-cyan-200/70 text-cyan-900 dark:bg-cyan-900/50 dark:text-cyan-300`
}
@@ -224,6 +227,11 @@ const badgeClass = computed(() => {
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-teal-50 text-teal-700 dark:bg-teal-900/20 dark:text-teal-400'
}
if (props.platform === 'minimax') {
return isSubscription.value
? 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
: 'bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400'
}
if (props.platform === 'composite') {
return isSubscription.value
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300'
@@ -38,6 +38,12 @@
d="M11.991 23.503a.24.24 0 0 0-.244.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.22-.247zM9.671 5.365a1.697 1.697 0 0 1 1.099 2.132l-.071.172l-.016.04l-.018.054c-.07.16-.104.32-.104.498c-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523c-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368c0 .124.018.23.053.337c.209.373.54.658.96.8c.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711c.854.284 1.378 1.226 1.099 2.167a1.66 1.66 0 0 1-2.077 1.102a1.7 1.7 0 0 1-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 0 0-1.954.746a1.66 1.66 0 0 1-1.065.764a1.677 1.677 0 0 1-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.5 1.5 0 0 1 .296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.3 1.3 0 0 0-.227-.693a2.5 2.5 0 0 1-.366-1.403a2.4 2.4 0 0 1 .366-1.208c.14-.195.21-.444.227-.693c.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.4 1.4 0 0 1-.299-.07l-.05-.019a1.7 1.7 0 0 1-1.047-2.114a1.68 1.68 0 0 1 2.094-1.101m-5.575 10.11c.26-.264.639-.367.994-.27s.633.379.728.74c.095.362-.007.748-.267 1.013c-.402.41-1.053.41-1.455 0a1.06 1.06 0 0 1 0-1.482zm14.845-.294c.359-.09.738.024.992.297c.254.274.344.665.237 1.025s-.396.634-.756.718c-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 0 1 .757-1.26zm-.064-4.39c.314.32.49.753.49 1.206s-.176.886-.49 1.206c-.315.32-.74.5-1.185.5c-.444 0-.87-.18-1.184-.5a1.727 1.727 0 0 1 0-2.412a1.654 1.654 0 0 1 2.369 0m-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 0 1-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 0 1 1.33-1.038a1.66 1.66 0 0 1 1.547.652m11.545-4.221c.368 0 .708.2.892.524s.184.724 0 1.048a1.03 1.03 0 0 1-.892.524a1.04 1.04 0 0 1-1.03-1.048a1.04 1.04 0 0 1 1.03-1.048m-14.358 0c.368 0 .707.2.891.524s.184.724 0 1.048a1.03 1.03 0 0 1-.891.524a1.04 1.04 0 0 1-1.03-1.048c0-.579.461-1.048 1.03-1.048m10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705s-1.674-.763-1.674-1.705s.75-1.706 1.674-1.706m-2.626-.684c.362-.082.653-.356.761-.718a1.06 1.06 0 0 0-.238-1.028a1.02 1.02 0 0 0-.996-.294c-.547.14-.881.7-.752 1.257c.13.558.675.907 1.225.783m0 16.876c.359-.087.644-.36.75-.72a1.06 1.06 0 0 0-.237-1.019a1.02 1.02 0 0 0-.985-.301a1.04 1.04 0 0 0-.762.717c-.108.361-.017.754.239 1.028c.245.263.606.377.953.305zM17.19 3.5a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64a.63.63 0 0 0-.628.64c0 .355.28.64.628.64m-10.38 0a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64a.63.63 0 0 0-.628.64c0 .355.279.64.628.64m-5.182 7.852a.63.63 0 0 0-.628.64c0 .354.28.639.628.639a.63.63 0 0 0 .627-.606l.001-.034a.62.62 0 0 0-.628-.64zm5.182 9.13a.63.63 0 0 0-.628.64c0 .355.279.64.628.64a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64m10.38.018a.63.63 0 0 0-.628.64c0 .355.28.64.628.64a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64m5.182-9.148a.63.63 0 0 0-.628.64c0 .354.279.639.628.639a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 0 0 .244-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249c0 .142.122.249.244.249M11.991.497a.24.24 0 0 0 .245-.248A.24.24 0 0 0 11.99 0a.24.24 0 0 0-.244.249c0 .133.108.236.223.247zM2.011 6.36a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249a.24.24 0 0 0 .244.249zm0 11.263a.24.24 0 0 0-.243.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .244-.249a.25.25 0 0 0-.244-.248zm19.995-.018a.24.24 0 0 0-.245.248a.24.24 0 0 0 .245.25a.24.24 0 0 0 .244-.25a.25.25 0 0 0-.244-.248z"
/>
</svg>
<!-- MiniMax official logo mark (overlapping M bars) -->
<svg v-else-if="platform === 'minimax'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path
d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"
/>
</svg>
<!-- DeepSeek official logo mark (whale) -->
<svg v-else-if="platform === 'deepseek'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -68,6 +68,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AccountPlatform, AccountType } from '@/types'
import { platformLabel as sharedPlatformLabel } from '@/utils/platformColors'
import GrokFreeIcon from './GrokFreeIcon.vue'
import PlatformIcon from './PlatformIcon.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -85,16 +86,7 @@ interface Props {
const props = defineProps<Props>()
const platformLabel = computed(() => {
if (props.platform === 'anthropic') return 'Anthropic'
if (props.platform === 'openai') return 'OpenAI'
if (props.platform === 'antigravity') return 'Antigravity'
if (props.platform === 'grok') return 'Grok'
if (props.platform === 'kimi') return 'Kimi'
if (props.platform === 'zhipu') return 'Zhipu GLM'
if (props.platform === 'deepseek') return 'DeepSeek'
return 'Gemini'
})
const platformLabel = computed(() => sharedPlatformLabel(props.platform))
const normalizedAuthMode = computed(() =>
(props.authMode || '').trim().toLowerCase().replace(/[\s_-]+/g, '')
@@ -200,6 +192,9 @@ const platformClass = computed(() => {
if (props.platform === 'deepseek') {
return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
}
if (props.platform === 'minimax') {
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400'
}
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -225,6 +220,9 @@ const typeClass = computed(() => {
if (props.platform === 'deepseek') {
return 'bg-teal-100 text-teal-600 dark:bg-teal-900/30 dark:text-teal-400'
}
if (props.platform === 'minimax') {
return 'bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400'
}
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -114,3 +114,19 @@ describe('PlatformTypeBadge OpenAI authentication modes', () => {
expect(wrapper.text()).toContain('OAuth')
})
})
describe('PlatformTypeBadge MiniMax', () => {
it('labels MiniMax API keys as MiniMax, not Gemini', () => {
const wrapper = mount(PlatformTypeBadge, {
props: {
platform: 'minimax',
type: 'apikey',
},
})
expect(wrapper.text()).toContain('MiniMax')
expect(wrapper.text()).toContain('Key')
expect(wrapper.text()).not.toContain('Gemini')
expect(wrapper.html()).toContain('bg-rose-100')
})
})
+19 -1
View File
@@ -469,6 +469,7 @@ const clientTabs = computed((): TabConfig[] => {
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
case 'deepseek':
case 'minimax':
case 'composite':
return [
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
@@ -517,6 +518,7 @@ const platformDescription = computed(() => {
props.platform !== 'openai' &&
props.platform !== 'grok' &&
props.platform !== 'deepseek' &&
props.platform !== 'minimax' &&
props.platform !== 'composite') {
return t('keys.useKeyModal.routedCodex.description')
}
@@ -542,6 +544,10 @@ const platformDescription = computed(() => {
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.deepseek.codexDescription')
: t('keys.useKeyModal.deepseek.description')
case 'minimax':
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.minimax.codexDescription')
: t('keys.useKeyModal.minimax.description')
case 'composite':
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.composite.codexDescription')
@@ -556,6 +562,7 @@ const platformNote = computed(() => {
props.platform !== 'openai' &&
props.platform !== 'grok' &&
props.platform !== 'deepseek' &&
props.platform !== 'minimax' &&
props.platform !== 'composite') {
return t('keys.useKeyModal.routedCodex.note')
}
@@ -594,6 +601,10 @@ const platformNote = computed(() => {
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.deepseek.codexNote')
: t('keys.useKeyModal.note')
case 'minimax':
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.minimax.codexNote')
: t('keys.useKeyModal.note')
case 'composite':
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.composite.codexNote')
@@ -757,6 +768,11 @@ const currentFiles = computed((): FileConfig[] => {
return generateRoutedCodexFiles(apiBase, apiKey, 'deepseek')
}
return generateAnthropicFiles(baseRoot, apiKey)
case 'minimax':
if (activeClientTab.value === 'codex') {
return generateRoutedCodexFiles(apiBase, apiKey, 'minimax')
}
return generateAnthropicFiles(baseRoot, apiKey)
case 'composite':
if (activeClientTab.value === 'codex') {
return generateRoutedCodexFiles(apiBase, apiKey, 'composite')
@@ -1219,6 +1235,7 @@ function generateRoutedCodexFiles(
kimi: 'kimi-k2.5',
zhipu: 'glm-4.7',
deepseek: 'deepseek-v4-pro',
minimax: 'MiniMax-M3',
composite: 'gpt-5.5'
}
const preferredModel = preferredModels[platform] || ''
@@ -1232,6 +1249,7 @@ function generateRoutedCodexFiles(
kimi: 'Kimi',
zhipu: 'Zhipu',
deepseek: 'DeepSeek',
minimax: 'MiniMax',
composite: 'Composite'
}
const label = labels[platform]
@@ -1260,7 +1278,7 @@ supports_websockets = false`
path: joinConfigPath(configDir, 'config.toml', isWindows),
content: configContent,
hint: t(
platform === 'deepseek' || platform === 'composite'
platform === 'deepseek' || platform === 'minimax' || platform === 'composite'
? `keys.useKeyModal.${platform}.codexConfigTomlHint`
: 'keys.useKeyModal.routedCodex.configTomlHint'
)
@@ -796,7 +796,7 @@ describe('UseKeyModal', () => {
)
})
it.each(['anthropic', 'gemini', 'antigravity', 'kimi', 'zhipu'] as const)(
it.each(['anthropic', 'gemini', 'antigravity', 'kimi', 'zhipu', 'minimax'] as const)(
'offers Codex catalog configuration for the %s routed group',
async (platform) => {
const wrapper = mount(UseKeyModal, {
@@ -94,7 +94,12 @@ const PLATFORM_LABELS: Record<string, string> = {
anthropic: 'Claude',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity'
antigravity: 'Antigravity',
grok: 'Grok',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
minimax: 'MiniMax',
}
function platformLabel(platform: string): string {
@@ -251,7 +251,12 @@ const PLATFORM_LABELS: Record<string, string> = {
anthropic: 'Claude',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity'
antigravity: 'Antigravity',
grok: 'Grok',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
minimax: 'MiniMax',
}
const platformLabel = (p: string) => PLATFORM_LABELS[p] ?? p
@@ -78,6 +78,11 @@ const PROVIDER_ICONS: Record<Provider, IconData> = {
'M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136a9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287a.3.3 0 0 1 .113.074a.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078a.253.253 0 0 1-.114-.358a1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45',
],
},
minimax: {
paths: [
'M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z',
],
},
}
const props = withDefaults(defineProps<{
@@ -21,6 +21,7 @@ import {
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDER_MINIMAX,
PROVIDERS,
STATUS_OPERATIONAL,
STATUS_DEGRADED,
@@ -114,6 +115,8 @@ export function useChannelMonitorFormat() {
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
case PROVIDER_DEEPSEEK:
return 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300'
case PROVIDER_MINIMAX:
return 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300'
default:
return NEUTRAL_BADGE
}
@@ -174,6 +177,10 @@ export function useChannelMonitorFormat() {
return active
? 'border-teal-500 bg-teal-50 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300 dark:border-teal-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-teal-300 hover:text-teal-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-teal-500/50'
case PROVIDER_MINIMAX:
return active
? 'border-rose-500 bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-rose-300 hover:text-rose-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-rose-500/50'
default:
return active
? 'border-gray-400 bg-gray-50 text-gray-700 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-200'
@@ -258,6 +265,8 @@ export function providerGradient(provider: string): string {
return 'bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-500/10 dark:to-indigo-500/20'
case PROVIDER_DEEPSEEK:
return 'bg-gradient-to-br from-teal-50 to-teal-100 dark:from-teal-500/10 dark:to-teal-500/20'
case PROVIDER_MINIMAX:
return 'bg-gradient-to-br from-rose-50 to-rose-100 dark:from-rose-500/10 dark:to-rose-500/20'
default:
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
}
@@ -206,6 +206,14 @@ const doubaoModels = [
// MiniMax
const minimaxModels = [
'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',
'abab6.5-chat', 'abab6.5s-chat', 'abab6.5s-chat-pro',
'abab6-chat',
'abab5.5-chat', 'abab5.5s-chat'
@@ -9,7 +9,8 @@ const concretePlatforms = [
'grok',
'kimi',
'zhipu',
'deepseek'
'deepseek',
'minimax'
]
describe('platform option catalogs', () => {
+3
View File
@@ -17,6 +17,7 @@ export const PROVIDER_ANTIGRAVITY: Provider = 'antigravity'
export const PROVIDER_KIMI: Provider = 'kimi'
export const PROVIDER_ZHIPU: Provider = 'zhipu'
export const PROVIDER_DEEPSEEK: Provider = 'deepseek'
export const PROVIDER_MINIMAX: Provider = 'minimax'
export const DEFAULT_GROK_ENDPOINT = 'https://api.x.ai'
export const DEFAULT_GROK_MODEL = 'grok-4.5'
@@ -25,6 +26,7 @@ export const DEFAULT_GROK_MODEL = 'grok-4.5'
export const DEFAULT_KIMI_ENDPOINT = 'https://api.moonshot.cn'
export const DEFAULT_ZHIPU_ENDPOINT = 'https://open.bigmodel.cn'
export const DEFAULT_DEEPSEEK_ENDPOINT = 'https://api.deepseek.com'
export const DEFAULT_MINIMAX_ENDPOINT = 'https://api.minimaxi.com'
export const CHECK_MODE_PROBE: CheckMode = 'probe'
export const CHECK_MODE_QUOTA: CheckMode = 'quota'
@@ -42,6 +44,7 @@ export const PROVIDERS: readonly Provider[] = [
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDER_MINIMAX,
]
/** 仅支持配额模式(无探活 adapter)的 provider。 */
+2 -1
View File
@@ -18,7 +18,8 @@ export const CONCRETE_PLATFORM_OPTIONS = [
{ value: 'grok', label: 'Grok' },
{ value: 'kimi', label: 'Kimi' },
{ value: 'zhipu', label: 'Zhipu GLM' },
{ value: 'deepseek', label: 'DeepSeek' }
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'minimax', label: 'MiniMax' }
] as const satisfies readonly PlatformOption<AccountPlatform>[]
/** Platforms that can own a group. */
@@ -387,6 +387,10 @@ function platformLabel(value: string) {
kiro: 'Kiro',
gemini: 'Gemini',
antigravity: 'Antigravity',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
minimax: 'MiniMax',
composite: 'Composite',
} as Record<string, string>
)[value] || value
@@ -107,6 +107,7 @@ export default {
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
minimax: 'MiniMax',
},
cnProviders: {
accountMode: {
@@ -974,6 +974,7 @@ export default {
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
minimax: 'MiniMax',
composite: 'Composite',
},
deleteConfirm:
+8 -1
View File
@@ -194,6 +194,12 @@ export default {
codexConfigTomlHint: 'Download the model catalog below, save both files under the Codex config directory, and restart Codex.',
codexNote: 'Export SUB2API_API_KEY before starting Codex. The downloaded catalog contains model metadata only, not your API key.',
},
minimax: {
description: 'Configure Claude Code, Codex, or OpenCode through the current MiniMax group.',
codexDescription: 'Configure Codex with API key authentication through the current MiniMax group.',
codexConfigTomlHint: 'Download the model catalog below, save both files under the Codex config directory, and restart Codex.',
codexNote: 'Export SUB2API_API_KEY before starting Codex. The downloaded catalog contains model metadata only, not your API key.',
},
composite: {
description: 'Configure supported clients through the current Composite routing group.',
codexDescription: 'Configure Codex with API key authentication and the complete model catalog for this Composite group.',
@@ -466,7 +472,8 @@ export default {
antigravity: 'Antigravity',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek'
deepseek: 'DeepSeek',
minimax: 'MiniMax'
},
// Check modes (how a monitor performs its checks)
checkMode: {
@@ -310,6 +310,7 @@ export default {
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek',
minimax: 'MiniMax',
},
cnProviders: {
accountMode: {

Some files were not shown because too many files have changed in this diff Show More