diff --git a/backend/ent/schema/user_platform_quota.go b/backend/ent/schema/user_platform_quota.go index 123d7c0bfa3f..abe4b2fd8d20 100644 --- a/backend/ent/schema/user_platform_quota.go +++ b/backend/ent/schema/user_platform_quota.go @@ -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) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8c856cbf6136..bd8261bcd7ae 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2044,6 +2044,7 @@ func setDefaults() { "api.moonshot.ai", "api.moonshot.cn", "open.bigmodel.cn", + "api.minimax.io", "api.minimaxi.com", "generativelanguage.googleapis.com", "cloudcode-pa.googleapis.com", diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 1ed90ee940c4..98254d6b271b 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -27,6 +27,7 @@ const ( PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot) PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel) PlatformDeepseek = "deepseek" // DeepSeek + PlatformMiniMax = "minimax" // MiniMax 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,适配 Codex) + APIProtocolResponses = "responses" // OpenAI Responses(deepseek/minimax,适配 Codex) APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点 ) diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index c1b3507aef8a..8be284a62929 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -620,6 +620,7 @@ var platformToLiteLLMProvider = map[string]string{ service.PlatformKimi: "moonshot", service.PlatformZhipu: "zhipu", service.PlatformDeepseek: "deepseek", + service.PlatformMiniMax: "minimax", } // SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表 diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 99833e912a49..58740a314c1c 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -98,7 +98,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic 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"` @@ -166,7 +166,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"` @@ -234,7 +234,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"` diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 6e068a46149f..cce6836ee147 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -34,6 +34,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 { @@ -55,7 +56,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) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 02e84fdcaee8..ee0819f0f10c 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1220,7 +1220,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 的 @@ -1432,6 +1432,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.7-highspeed"} default: return defaultModelIDsForPlatform(platform) } @@ -1461,7 +1463,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 diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 8059611faa05..79e81444f004 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -794,13 +794,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.7-highspeed"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax)) require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic)) } diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go index eefb0948cb97..f67860de00d8 100644 --- a/backend/internal/handler/openai_gateway_cn_dispatch_test.go +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -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) @@ -43,7 +43,7 @@ func TestAllowOpenAICompatibleMessagesDispatch_CompositeResolvedTargets(t *testi } // 解析到 grok/CN 目标:与对应独立分组同语义豁免。 - for _, model := range []string{"grok-4.3", "kimi-k2-thinking", "glm-5.2", "deepseek-v3.2"} { + for _, model := range []string{"grok-4.3", "kimi-k2-thinking", "glm-5.2", "deepseek-v3.2", "MiniMax-M3"} { c, apiKey := newCompositeCtx(model, false) require.True(t, allowOpenAICompatibleMessagesDispatch(c, apiKey), "model=%s", model) } @@ -66,7 +66,7 @@ func TestAllowOpenAICompatibleMessagesDispatch_CompositeResolvedTargets(t *testi func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMapping(t *testing.T) { gin.SetMode(gin.TestMode) - for _, model := range []string{"kimi-k2-thinking", "glm-5.2", "deepseek-v3.2", "grok-4.3"} { + for _, model := range []string{"kimi-k2-thinking", "glm-5.2", "deepseek-v3.2", "MiniMax-M3", "grok-4.3"} { c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest("POST", "/v1/messages", nil) apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 6b2fda70afe3..f07b9ef8dd1e 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -242,11 +242,11 @@ 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 -// 上可服务的目标平台。CN 供应商(kimi/zhipu/deepseek)刻意排除:其账号无法通过 +// 上可服务的目标平台。CN 供应商刻意排除:其账号无法通过 // WSv2 ingress 的 transport 过滤,且 WS HTTP 桥没有面向 CN 的 Responses 转换, // 放行只会把明确的策略拒绝变成误导性的 "no available account"。 func isResponsesWebSocketCompositePlatform(platform string) bool { diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index bb3a3157718b..c0cdfc4c6c8e 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -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, } } diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go index fc9ff5b385a9..7b69b1cf2182 100644 --- a/backend/internal/model/error_passthrough_rule_test.go +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -16,5 +16,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { "kimi", "zhipu", "deepseek", + "minimax", }, AllPlatforms()) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d1641490c4ee..5322e30efa38 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -861,7 +861,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, @@ -1177,7 +1177,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, diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index fe753b238993..183e462e6be8 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -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) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 3967e407bdda..9d74706c8208 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -285,17 +285,22 @@ 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 兼容供应商。 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) + a.Platform == PlatformKimi || a.Platform == PlatformZhipu || a.Platform == PlatformDeepseek || + a.Platform == PlatformMiniMax) } func (a *Account) GeminiOAuthType() string { @@ -1330,7 +1335,7 @@ func (a *Account) IsOpenAIApiKey() bool { } // GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。 -// 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL, +// 适用 openai 与国产 OpenAI 兼容供应商;grok 走 GetGrokBaseURL, // 此处对 grok 返回 "" 以保持原有行为。 func (a *Account) GetOpenAIBaseURL() string { if !a.IsOpenAI() && !a.IsCNProvider() { @@ -1362,6 +1367,8 @@ func (a *Account) GetOpenAIBaseURL() string { return DefaultZhipuPayGBaseURL case PlatformDeepseek: return DefaultDeepseekBaseURL + case PlatformMiniMax: + return DefaultMiniMaxBaseURL default: return "https://api.openai.com" } @@ -1387,8 +1394,8 @@ func (a *Account) IsCodingPlan() bool { // GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于 // credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions -// (与既有行为完全一致)。responses 协议仅 deepseek 支持(官方原生 /responses -// 端点,适配 Codex);kimi/zhipu 无此端点。 +// (与既有行为完全一致)。responses 协议仅 deepseek/minimax 支持; +// kimi/zhipu 无此端点。 func (a *Account) GetAPIProtocol() string { if a == nil || !a.IsCNProvider() { return APIProtocolChatCompletions @@ -1399,7 +1406,7 @@ func (a *Account) GetAPIProtocol() string { case APIProtocolAnthropic: return APIProtocolAnthropic case APIProtocolResponses: - if a.Platform == PlatformDeepseek { + if a.SupportsNativeResponses() { return APIProtocolResponses } case APIProtocolChatCompletions: @@ -1408,6 +1415,10 @@ func (a *Account) GetAPIProtocol() string { return APIProtocolChatCompletions } +func (a *Account) SupportsNativeResponses() bool { + return a != nil && (a.Platform == PlatformDeepseek || a.Platform == PlatformMiniMax) +} + // IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。 func (a *Account) IsAdaptiveAPIProtocol() bool { return a.GetAPIProtocol() == APIProtocolAdaptive @@ -1448,6 +1459,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string { return DefaultZhipuAnthropicBaseURL case PlatformDeepseek: return DefaultDeepseekAnthropicBaseURL + case PlatformMiniMax: + return DefaultMiniMaxAnthropicBaseURL } case APIProtocolChatCompletions, APIProtocolResponses: switch a.Platform { @@ -1463,6 +1476,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string { return DefaultZhipuPayGBaseURL case PlatformDeepseek: return DefaultDeepseekBaseURL + case PlatformMiniMax: + return DefaultMiniMaxBaseURL } } return "" @@ -1499,6 +1514,8 @@ func (a *Account) GetAnthropicProtocolBaseURL() string { return DefaultZhipuAnthropicBaseURL case PlatformDeepseek: return DefaultDeepseekAnthropicBaseURL + case PlatformMiniMax: + return DefaultMiniMaxAnthropicBaseURL default: return "" } @@ -1526,12 +1543,14 @@ func (a *Account) GetOpenAIFormatBaseURL() string { return DefaultZhipuPayGBaseURL case PlatformDeepseek: return DefaultDeepseekBaseURL + case PlatformMiniMax: + return DefaultMiniMaxBaseURL default: return a.GetOpenAIBaseURL() } } -// GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。 +// GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据。 // 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。 func (a *Account) GetCNAPIKey() string { if a == nil || !a.IsCNProvider() { diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go index 078d98d832c1..2b38d19c3553 100644 --- a/backend/internal/service/account_header_override.go +++ b/backend/internal/service/account_header_override.go @@ -70,7 +70,7 @@ func isHeaderOverrideBlockedName(lowerName string) bool { } // IsHeaderOverrideEligible 报告账号类型是否支持请求头覆写。 -// Anthropic / OpenAI / Kimi / Zhipu / DeepSeek 仅开放 api_key 账号; +// Anthropic / OpenAI 与国产供应商仅开放 api_key 账号; // Grok 额外开放 oauth 账号—— // 订阅流量改发自定义转发地址时,通常需要补充中间层要求的准入头。 func (a *Account) IsHeaderOverrideEligible() bool { @@ -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 diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 96689e5000fd..3343a625b61e 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -514,7 +514,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: diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index 8811fd840f50..923fc07e8fb1 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -19,7 +19,7 @@ const accountTestSuppressCompletionContextKey = "account_test_suppress_completio // testCNProviderAdaptiveConnection verifies every native endpoint used by an // adaptive CN-provider account. Kimi and Zhipu use Chat Completions plus -// Anthropic; DeepSeek additionally uses its native Responses endpoint. +// Anthropic; providers with native Responses support also verify that endpoint. func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error { testModelID := strings.TrimSpace(modelID) if testModelID == "" { @@ -44,7 +44,7 @@ func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, ac return err } - if account.Platform == PlatformDeepseek { + if account.SupportsNativeResponses() { if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil { return err } @@ -159,8 +159,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co apiURL := buildOpenAIResponsesURLForPlatform(account.Platform, baseURL) payload := createOpenAITestPayload(testModelID, false) - // DeepSeek's native Responses endpoint is stateless and does not need the - // OpenAI probe's synthetic instructions. + // Native CN Responses probes do not need the OpenAI probe's synthetic instructions. delete(payload, "instructions") payloadBytes, _ := json.Marshal(payload) payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes) diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go index 410cc26261f3..8133e7b35cec 100644 --- a/backend/internal/service/account_test_service_cn_adaptive_test.go +++ b/backend/internal/service/account_test_service_cn_adaptive_test.go @@ -115,28 +115,40 @@ func TestAccountTestService_AdaptiveChatOnlyProvidersTestChatAndAnthropicEndpoin } } -func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testing.T) { - account := adaptiveCNAccountTestAccount(302, PlatformDeepseek) - svc, upstream := adaptiveCNAccountTestService( - account, - adaptiveCNChatTestResponse(), - adaptiveCNAnthropicTestResponse(), - adaptiveCNResponsesTestResponse(), - ) - c, recorder := newTestContext() +func TestAccountTestService_AdaptiveNativeResponsesProvidersTestAllEndpoints(t *testing.T) { + for index, testCase := range []struct { + name string + platform string + model string + wantResponsesURL string + }{ + {name: "DeepSeek", platform: PlatformDeepseek, model: "deepseek-chat", wantResponsesURL: "http://responses.example/responses"}, + {name: "MiniMax", platform: PlatformMiniMax, model: "MiniMax-M3", wantResponsesURL: "http://responses.example/v1/responses"}, + } { + t.Run(testCase.name, func(t *testing.T) { + account := adaptiveCNAccountTestAccount(int64(302+index), testCase.platform) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + adaptiveCNResponsesTestResponse(), + ) + c, recorder := newTestContext() - err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault) + err := svc.TestAccountConnection(c, account.ID, testCase.model, "", AccountTestModeDefault) - require.NoError(t, err) - require.Len(t, upstream.requests, 3) - require.Equal(t, "http://responses.example/responses", upstream.requests[2].URL.String()) - require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[2].Context())) - require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[2].Header.Get("Authorization")) - require.True(t, gjson.GetBytes(upstream.bodies[2], "stream").Bool()) - require.False(t, gjson.GetBytes(upstream.bodies[2], "store").Bool()) - require.False(t, gjson.GetBytes(upstream.bodies[2], "instructions").Exists()) - require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) - require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证") + require.NoError(t, err) + require.Len(t, upstream.requests, 3) + require.Equal(t, testCase.wantResponsesURL, upstream.requests[2].URL.String()) + require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[2].Context())) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[2].Header.Get("Authorization")) + require.True(t, gjson.GetBytes(upstream.bodies[2], "stream").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "store").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "instructions").Exists()) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证") + }) + } } func TestAccountTestService_AdaptiveStopsAndNamesFailingEndpoint(t *testing.T) { diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 7b64e4b60fc7..8fd3e778f56a 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -247,6 +247,8 @@ func defaultModelsListCandidateIDs(platform string) []string { return ids case PlatformGrok: return xai.DefaultModelIDs() + case PlatformMiniMax: + return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"} case PlatformComposite: return compositeDefaultModelsListCandidateIDs() default: @@ -267,7 +269,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 diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index a2727af7a45f..488b3b5fd24e 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -618,43 +618,53 @@ func (s *BillingService) initFallbackPricing() { // ---- MiniMax M 系列 ---- // Source: https://platform.minimax.io/docs/guides/pricing-paygo - // 注意:MiniMax M3 在 >512K context 时价格翻倍,本兜底采用 ≤512K 标准 tier(保守口径,对用户有利)。 - // 如需支持长上下文 multiplier,可后续参考 GPT-5.4 模式扩展 LongContextXxx 字段。 + // MiniMax M3 在 >512K 输入时标准价和 Priority 价均翻倍;Priority 为标准价 1.5 倍。 s.fallbackPrices["minimax-m3"] = &ModelPricing{ - InputPricePerToken: 0.60e-6, // $0.60 per MTok (≤512K standard tier, 含 50% 永久折扣前原价 $1.20) - OutputPricePerToken: 2.40e-6, - CacheReadPricePerToken: 0.12e-6, - SupportsCacheBreakdown: false, + InputPricePerToken: 0.30e-6, + InputPricePerTokenPriority: 0.45e-6, + OutputPricePerToken: 1.20e-6, + OutputPricePerTokenPriority: 1.80e-6, + CacheReadPricePerToken: 0.06e-6, + CacheReadPricePerTokenPriority: 0.09e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: 512000, + LongContextInputMultiplier: 2.0, + LongContextOutputMultiplier: 2.0, } s.fallbackPrices["minimax-m2.7"] = &ModelPricing{ - InputPricePerToken: 0.30e-6, // $0.30 per MTok - OutputPricePerToken: 1.20e-6, - CacheReadPricePerToken: 0.06e-6, - SupportsCacheBreakdown: false, + InputPricePerToken: 0.30e-6, + OutputPricePerToken: 1.20e-6, + CacheCreationPricePerToken: 0.375e-6, + CacheReadPricePerToken: 0.06e-6, + SupportsCacheBreakdown: false, } s.fallbackPrices["minimax-m2.7-highspeed"] = &ModelPricing{ - InputPricePerToken: 0.60e-6, - OutputPricePerToken: 2.40e-6, - CacheReadPricePerToken: 0.06e-6, - SupportsCacheBreakdown: false, + InputPricePerToken: 0.60e-6, + OutputPricePerToken: 2.40e-6, + CacheCreationPricePerToken: 0.375e-6, + CacheReadPricePerToken: 0.06e-6, + SupportsCacheBreakdown: false, } s.fallbackPrices["minimax-m2.5"] = &ModelPricing{ - InputPricePerToken: 0.30e-6, - OutputPricePerToken: 1.20e-6, - CacheReadPricePerToken: 0.03e-6, - SupportsCacheBreakdown: false, + InputPricePerToken: 0.30e-6, + OutputPricePerToken: 1.20e-6, + CacheCreationPricePerToken: 0.375e-6, + CacheReadPricePerToken: 0.03e-6, + SupportsCacheBreakdown: false, } s.fallbackPrices["minimax-m2.1"] = &ModelPricing{ - InputPricePerToken: 0.30e-6, - OutputPricePerToken: 1.20e-6, - CacheReadPricePerToken: 0.03e-6, - SupportsCacheBreakdown: false, + InputPricePerToken: 0.30e-6, + OutputPricePerToken: 1.20e-6, + CacheCreationPricePerToken: 0.375e-6, + CacheReadPricePerToken: 0.03e-6, + SupportsCacheBreakdown: false, } s.fallbackPrices["minimax-m2"] = &ModelPricing{ - InputPricePerToken: 0.30e-6, - OutputPricePerToken: 1.20e-6, - CacheReadPricePerToken: 0.03e-6, - SupportsCacheBreakdown: false, + InputPricePerToken: 0.30e-6, + OutputPricePerToken: 1.20e-6, + CacheCreationPricePerToken: 0.375e-6, + CacheReadPricePerToken: 0.03e-6, + SupportsCacheBreakdown: false, } // ---- 火山方舟 豆包 Embedding(多模态向量化)---- diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 0f8bbacf9a8e..5466b2b949ca 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -721,16 +721,16 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { { name: "minimax m3", model: "minimax-m3", - expectedInput: 0.60e-6, - expectedOutput: floatPtr(2.40e-6), - expectedCacheRead: floatPtr(0.12e-6), + expectedInput: 0.30e-6, + expectedOutput: floatPtr(1.20e-6), + expectedCacheRead: floatPtr(0.06e-6), }, { - name: "minimax m3 long ctx boundary keep standard tier", - model: "minimax-m3-long", // 仍按 standard tier (≤512K) - expectedInput: 0.60e-6, - expectedOutput: floatPtr(2.40e-6), - expectedCacheRead: floatPtr(0.12e-6), + name: "minimax m3 alias uses base pricing", + model: "minimax-m3-long", + expectedInput: 0.30e-6, + expectedOutput: floatPtr(1.20e-6), + expectedCacheRead: floatPtr(0.06e-6), }, { name: "minimax m2.7", @@ -823,6 +823,44 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { } } +func TestCalculateCost_MiniMaxM3OfficialTiers(t *testing.T) { + svc := newTestBillingService() + tokens := UsageTokens{InputTokens: 511000, OutputTokens: 1000, CacheReadTokens: 1000} + + standard, err := svc.CalculateCostWithServiceTier("MiniMax-M3", tokens, 1, "standard") + require.NoError(t, err) + require.False(t, standard.LongContextBillingApplied) + require.InDelta(t, 511000*0.30e-6, standard.InputCost, 1e-12) + require.InDelta(t, 1000*1.20e-6, standard.OutputCost, 1e-12) + require.InDelta(t, 1000*0.06e-6, standard.CacheReadCost, 1e-12) + + priority, err := svc.CalculateCostWithServiceTier("MiniMax-M3", tokens, 1, "priority") + require.NoError(t, err) + require.False(t, priority.LongContextBillingApplied) + require.InDelta(t, 511000*0.45e-6, priority.InputCost, 1e-12) + require.InDelta(t, 1000*1.80e-6, priority.OutputCost, 1e-12) + require.InDelta(t, 1000*0.09e-6, priority.CacheReadCost, 1e-12) + + longContextTokens := UsageTokens{InputTokens: 511001, OutputTokens: 1000, CacheReadTokens: 1000} + longContext, err := svc.CalculateCostWithServiceTier("MiniMax-M3", longContextTokens, 1, "priority") + require.NoError(t, err) + require.True(t, longContext.LongContextBillingApplied) + require.InDelta(t, 511001*0.90e-6, longContext.InputCost, 1e-12) + require.InDelta(t, 1000*3.60e-6, longContext.OutputCost, 1e-12) + require.InDelta(t, 1000*0.18e-6, longContext.CacheReadCost, 1e-12) +} + +func TestCalculateCost_MiniMaxM27CacheWrite(t *testing.T) { + svc := newTestBillingService() + tokens := UsageTokens{CacheCreationTokens: 1000} + + for _, model := range []string{"MiniMax-M2.7", "MiniMax-M2.7-highspeed"} { + cost, err := svc.CalculateCost(model, tokens, 1) + require.NoError(t, err) + require.InDelta(t, 1000*0.375e-6, cost.CacheCreationCost, 1e-12, "model=%s", model) + } +} + // doubao-embedding-vision 是首个图文不同价的 embedding:文本 ¥0.7/MTok、图片 ¥1.8/MTok。 // 验证回退表同时携带文本与图片两档单价,且能被带版本后缀 / 大小写别名命中。 func TestGetModelPricing_DoubaoEmbeddingVisionImageInputRate(t *testing.T) { diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index b3fe7dc63a88..e71ce443e528 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -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} } diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index e443b80b592f..1bde091d4d22 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -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 { diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 7ec7a4fddd72..477e01a80a9f 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -398,6 +398,7 @@ func TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch(t *testing.T) { require.Equal(t, PlatformKimi, NormalizeOpenAICompatiblePlatform(PlatformKimi)) require.Equal(t, PlatformZhipu, NormalizeOpenAICompatiblePlatform(PlatformZhipu)) require.Equal(t, PlatformDeepseek, NormalizeOpenAICompatiblePlatform(PlatformDeepseek)) + require.Equal(t, PlatformMiniMax, NormalizeOpenAICompatiblePlatform(PlatformMiniMax)) // 其他平台(含空、anthropic、未知)一律归一为 openai。 require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform("")) require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(PlatformAnthropic)) @@ -451,6 +452,7 @@ 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", PlatformMiniMax, "", "https://api.minimax.io/v1/models"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -469,7 +471,7 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) { } // TestGetAPIProtocol 验证协议凭证维度的平台校验矩阵: -// responses 仅 deepseek;缺失/非法值回退 chat_completions(与旧行为一致)。 +// responses 仅原生支持的平台;缺失/非法值回退 chat_completions。 func TestGetAPIProtocol(t *testing.T) { t.Parallel() @@ -485,10 +487,13 @@ func TestGetAPIProtocol(t *testing.T) { require.Equal(t, APIProtocolAnthropic, mk(PlatformZhipu, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol()) + require.Equal(t, APIProtocolAnthropic, mk(PlatformMiniMax, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol()) + require.Equal(t, APIProtocolResponses, mk(PlatformMiniMax, APIProtocolResponses).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformMiniMax, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol(), "kimi 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认") @@ -511,6 +516,7 @@ 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", PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL}, } for _, tc := range cases { @@ -568,6 +574,10 @@ func TestAnthropicProtocolBaseURL(t *testing.T) { Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, }).GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://api.minimax.io/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{ @@ -638,6 +648,7 @@ func TestBuildOpenAIResponsesURLForPlatform(t *testing.T) { require.Equal(t, "https://relay.example.com/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com")) require.Equal(t, "https://relay.example.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com/v1")) require.Equal(t, "https://api.openai.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformOpenAI, "https://api.openai.com")) + require.Equal(t, "https://api.minimax.io/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformMiniMax, DefaultMiniMaxBaseURL)) require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", buildOpenAIResponsesURLForPlatform(PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4")) } diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index c7736f9582c9..3dc1c6c5059a 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -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,8 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformZhipu, true case strings.HasPrefix(normalized, "deepseek-"): return PlatformDeepseek, true + case strings.HasPrefix(normalized, "minimax-"): + return PlatformMiniMax, true default: return "", false } @@ -195,7 +199,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 diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index bff5928024b7..b9f3787056fc 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -176,6 +176,8 @@ 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 provider prefix", model: "minimax/MiniMax-M2.7", platform: PlatformMiniMax, ok: true}, {name: "unknown k3 alias", model: "k3-preview", ok: false}, {name: "unknown", model: "llama-4-maverick", ok: false}, } @@ -211,13 +213,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)) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 4679823777cf..46930a6f83cf 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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,7 @@ const ( DefaultZhipuPayGBaseURL = "https://open.bigmodel.cn/api/paas/v4" DefaultZhipuCodingBaseURL = "https://open.bigmodel.cn/api/coding/paas/v4" DefaultDeepseekBaseURL = "https://api.deepseek.com" + DefaultMiniMaxBaseURL = "https://api.minimax.io/v1" ) // 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。 @@ -84,12 +86,13 @@ const ( DefaultKimiCodingAnthropicBaseURL = "https://api.kimi.com/coding" DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic" DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic" + DefaultMiniMaxAnthropicBaseURL = "https://api.minimax.io/anthropic" ) -// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。 +// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商。 func IsCNProvider(platform string) bool { switch platform { - case PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: return true default: return false @@ -108,6 +111,7 @@ var AllowedQuotaPlatforms = []string{ PlatformKimi, PlatformZhipu, PlatformDeepseek, + PlatformMiniMax, } // AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 329cfebad4ca..478591ab76c4 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -123,12 +123,12 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte return } if account.IsCNProvider() { - // 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions, + // 国产 OpenAI 兼容上游普遍仅支持 /v1/chat/completions, // 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。 - // 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses - // 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。 + // 例外:原生支持 Responses 的固定 responses 和 adaptive 账号使用官方端点, + // 落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。 if account.GetAPIProtocol() == APIProtocolResponses || - (account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) { + (account.SupportsNativeResponses() && account.IsAdaptiveAPIProtocol()) { _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), openai_compat.ExtraKeyResponsesSupported: true, diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go index f3b4eb0e6d9e..e4bf57cc576f 100644 --- a/backend/internal/service/openai_apikey_responses_probe_test.go +++ b/backend/internal/service/openai_apikey_responses_probe_test.go @@ -62,6 +62,7 @@ func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) { {name: "deepseek chat clears forced responses", id: 202, platform: PlatformDeepseek, protocol: APIProtocolChatCompletions, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, {name: "kimi adaptive falls back to chat", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, {name: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + {name: "minimax adaptive supports responses", id: 205, platform: PlatformMiniMax, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)}, } for _, tc := range tests { diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a3a992175cef..1b2445acadb4 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -102,13 +102,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() // 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。 - // Responses 形状下,DeepSeek 继续走下方原生 Responses 链;Kimi/GLM + // Responses 形状下,支持原生 Responses 的平台继续走下方原生链;Kimi/GLM // 没有 Responses 端点,先转换成 Chat Completions 再直转。 if account.IsAdaptiveAPIProtocol() { if !isResponsesShape { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } - if account.Platform != PlatformDeepseek { + if !account.SupportsNativeResponses() { var responsesReq apicompat.ResponsesRequest if err := json.Unmarshal(body, &responsesReq); err != nil { return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) @@ -126,7 +126,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) } - // DeepSeek 原生 Responses 请求继续走下方 Responses→Chat 回程转换。 + // 原生 Responses 请求继续走下方 Responses→Chat 回程转换。 } // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点, diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 304af599093b..b865b5c99e6d 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -133,13 +133,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } - nativeDeepSeekResponses := account.Platform == PlatformDeepseek && + nativeCNResponses := account.SupportsNativeResponses() && (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol()) - if nativeDeepSeekResponses && account.Type == AccountTypeAPIKey && !compactPath && + if nativeCNResponses && account.Type == AccountTypeAPIKey && !compactPath && needsOpenAIResponsesClientToolAdaptation(body) { adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) if adaptErr != nil { - return nil, fmt.Errorf("adapt DeepSeek Responses client tools: %w", adaptErr) + return nil, fmt.Errorf("adapt CN Responses client tools: %w", adaptErr) } body = adaptedBody setOpenAIResponsesClientToolMapping(c, mapping) @@ -360,7 +360,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses { + if instructionsEmpty && !compatMessagesBridge && !nativeCNResponses { markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) } @@ -571,7 +571,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco maxOutputTokens := gjson.GetBytes(body, "max_output_tokens") if maxOutputTokens.Exists() { switch account.Platform { - case PlatformOpenAI, PlatformDeepseek: + case PlatformOpenAI, PlatformDeepseek, PlatformMiniMax: // Preserve Responses-native output limits unless the selected upstream // explicitly rejects the field in the bounded HTTP retry loop below. case PlatformAnthropic: @@ -1268,13 +1268,13 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { return false } if account.IsCNProvider() { - // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生 - // Responses,Kimi/GLM 回退 Chat Completions。 + // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅原生支持 + // Responses 的平台直通,Kimi/GLM 回退 Chat Completions。 switch account.GetAPIProtocol() { case APIProtocolChatCompletions: return true case APIProtocolAdaptive: - return account.Platform != PlatformDeepseek + return !account.SupportsNativeResponses() default: return false } @@ -1298,7 +1298,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. case AccountTypeAPIKey: // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() - if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() { + if account.SupportsNativeResponses() && account.IsAdaptiveAPIProtocol() { baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) } if baseURL == "" { diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index a4bc7b9f3a3d..911c303b453c 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -1,6 +1,6 @@ package service -// 国产供应商(kimi/zhipu/deepseek)原生 Anthropic 端点直通路径。 +// 国产供应商原生 Anthropic 端点直通路径。 // // 当账号 credentials["api_protocol"] = "anthropic" 时,入站 /v1/messages 请求 // 不再做 Anthropic→CC→Anthropic 双重转换,而是零转换直通供应商的官方 diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index a90bad3e2589..3d986e38414b 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -285,14 +285,14 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount( ) } -// NormalizeOpenAICompatiblePlatform 保留 grok 与国产 OpenAI 兼容供应商(kimi/zhipu/ -// deepseek)的原值,其他值一律归一为 openai。调度器据此对账号与请求做精确平台匹配: +// NormalizeOpenAICompatiblePlatform 保留 grok 与国产 OpenAI 兼容供应商的原值, +// 其他值一律归一为 openai。调度器据此对账号与请求做精确平台匹配: // kimi 分组请求只命中 kimi 账号,语义与 openai/grok 一致。 // (upstream 曾将本函数改为未导出 normalizeOpenAICompatiblePlatform,本分支的 // handler 调度入口仍需导出,保持导出名。) func NormalizeOpenAICompatiblePlatform(platform string) string { switch platform { - case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: return platform default: return PlatformOpenAI diff --git a/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go index e52adbb4c843..4015d04093f2 100644 --- a/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go @@ -290,13 +290,13 @@ 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") + require.Len(t, cache.tokens(), 40, "full rebuild and the following group event must each run fresh authority") _, reopenHeld := cache.lifecycleMutationLeaseStates() - require.Len(t, reopenHeld, 36) + require.Len(t, reopenHeld, 40) for _, held := range reopenHeld { require.True(t, held) } - require.Equal(t, 30, accounts.callCount()) + require.Equal(t, 33, accounts.callCount()) } func TestSchedulerFullRebuildGlobalReadErrorsFailBeforeMutationOrDB(t *testing.T) { @@ -367,15 +367,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, 20, reopenCount) + require.Equal(t, 21, 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, 23, accounts.callCount()) + require.Equal(t, 12, accounts.groupCallCount(groupID)) _, historicalPublished := cache.counts(historical) require.Equal(t, 1, historicalPublished) activeCalls, fallbackCalls, freshCalls := groups.stats() @@ -398,7 +398,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, 22, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning") require.Zero(t, accounts.callCount()) require.Zero(t, cache.totalSetAttempts()) } @@ -416,8 +416,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, 43, cache.captureAttemptCount()) + require.Equal(t, 25, accounts.callCount()) groups.mu.Lock() require.Equal(t, 1, groups.listCalls) groups.mu.Unlock() @@ -456,7 +456,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, 11, 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 +528,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, 21, len(cache.retiredBuckets())) groups.mu.Lock() delete(groups.freshErr, 2) @@ -537,8 +537,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, 63, len(cache.retiredBuckets())) + require.Equal(t, 11, accounts.callCount()) require.Empty(t, cache.tokens()) } @@ -561,14 +561,14 @@ 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, 20, reopenCount) + require.Equal(t, 27, 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, 23, 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 +600,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, 23, cache.captureAttemptCount()) + require.Equal(t, 14, accounts.callCount()) + require.Equal(t, 14, accounts.groupCallCount(0)) require.Empty(t, cache.retiredBuckets()) require.Empty(t, cache.tokens()) for _, bucket := range registered { @@ -632,11 +632,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, 22, accounts.callCount()) svc.pollOutbox() require.Equal(t, int64(1), cache.currentWatermark()) - require.Equal(t, 40, accounts.callCount()) + require.Equal(t, 44, accounts.callCount()) _, busyBucketPublished := cache.counts(canonical[0]) require.Equal(t, 1, busyBucketPublished) activeCalls, fallbackCalls, freshCalls := groups.stats() @@ -654,7 +654,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, 11, accounts.callCount()) attempts, published := cache.counts(busyBucket) require.Zero(t, attempts) require.Zero(t, published) diff --git a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go index f9e75ed158c5..0c879e5057f1 100644 --- a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go @@ -326,7 +326,7 @@ func newGroupLifecycleTestService(cache SchedulerCache, accounts AccountReposito func expectedGroupLifecycleBuckets(groupID int64) []SchedulerBucket { platforms := schedulerSnapshotPlatforms() - buckets := make([]SchedulerBucket, 0, 18) + buckets := make([]SchedulerBucket, 0, 20) for _, platform := range platforms { buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, @@ -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, 20, 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(), 20) + require.Equal(t, 11, 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, 20) for _, held := range reopenHeld { require.True(t, held) } lockTTLs, unlockCalls := cache.lockStats() - require.Len(t, lockTTLs, 18) + require.Len(t, lockTTLs, 20) for _, ttl := range lockTTLs { require.Equal(t, 30*time.Second, ttl) } - require.Equal(t, 18, unlockCalls) + require.Equal(t, 20, 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(), 20) + require.Equal(t, 11, accounts.callCount()) for _, bucket := range expectedGroupLifecycleBuckets(groupID) { _, published := cache.counts(bucket) require.Equal(t, 1, published, bucket.String()) @@ -546,15 +546,15 @@ 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) + require.Len(t, firstActiveTokens, 20) 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, 40) + require.Greater(t, allTokens[20].Epoch, firstActiveTokens[0].Epoch) require.ErrorIs(t, cache.SetSnapshot(context.Background(), firstActiveTokens[0].Bucket, firstActiveTokens[0], nil), ErrSchedulerBucketWriteFenced) } @@ -571,11 +571,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, 11, 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, 11, accounts.callCount()) } func TestSchedulerGroupLifecycleFailuresDoNotMarkSeen(t *testing.T) { diff --git a/backend/internal/service/scheduler_snapshot_retirement_test.go b/backend/internal/service/scheduler_snapshot_retirement_test.go index 6fa2b7ddc580..d05a639a81e1 100644 --- a/backend/internal/service/scheduler_snapshot_retirement_test.go +++ b/backend/internal/service/scheduler_snapshot_retirement_test.go @@ -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, 40, 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) diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index e38d0291bafb..1f13f4bcb1b8 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -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。 @@ -837,7 +837,7 @@ func schedulerBucketsForGroup(groupID int64) []SchedulerBucket { } func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket { - buckets := make([]SchedulerBucket, 0, 18) + buckets := make([]SchedulerBucket, 0, 20) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, @@ -855,7 +855,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)*20) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, s.bucketsForPlatform(platform, groupIDs, seen)...) } diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 7255464c0302..990417ea4bf7 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -984,7 +984,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 @@ -1008,7 +1008,7 @@ func isUpstreamBillingProbeAccount(account *Account) bool { // ollama.com is a first-class configuration here (Ollama Cloud accounts are // platform openai/anthropic with base_url https://ollama.com/v1), and it is // an official provider API just like the rest, so it belongs on this list. -// CN provider domains (moonshot.cn / kimi.com / bigmodel.cn / deepseek.com) +// CN provider domains // serve the same role: official APIs that can never host /v1/sub2api/billing, // so their accounts short-circuit to "unsupported" without a request. var upstreamBillingProbeOfficialAPIDomains = []string{ @@ -1022,6 +1022,8 @@ var upstreamBillingProbeOfficialAPIDomains = []string{ "kimi.com", "bigmodel.cn", "deepseek.com", + "minimax.io", + "minimaxi.com", } func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { diff --git a/backend/migrations/232_add_minimax_platform.sql b/backend/migrations/232_add_minimax_platform.sql new file mode 100644 index 000000000000..ae2002580fa6 --- /dev/null +++ b/backend/migrations/232_add_minimax_platform.sql @@ -0,0 +1,18 @@ +-- Add MiniMax to the two database-level platform allowlists. +-- Keep both constraints aligned with the application platform catalogs so +-- quota initialization and composite routes accept MiniMax atomically. +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')); diff --git a/backend/migrations/minimax_platform_migration_test.go b/backend/migrations/minimax_platform_migration_test.go new file mode 100644 index 000000000000..809f8f60d2a5 --- /dev/null +++ b/backend/migrations/minimax_platform_migration_test.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMiniMaxPlatformMigration(t *testing.T) { + content, err := FS.ReadFile("232_add_minimax_platform.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check") + require.Contains(t, sql, + "CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax'))") + require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check") + require.Contains(t, sql, + "CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax'))") +} diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 1ff84f32755f..d52fa4193881 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -429,8 +429,8 @@ - -