From 19382f275e8fdc05a655e3e20fbd688bb9a7ec2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Mon, 7 Sep 2026 18:22:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(minimax):=20=E5=A2=9E=E5=8A=A0=20MiniMax?= =?UTF-8?q?=20=E5=B9=B3=E5=8F=B0=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 MiniMax 做成与 Kimi / 智谱 / DeepSeek 同级的国产供应商平台:账号创建/编辑、分组、Composite 路由、原生 Token Plan / Coding Plan 额度,以及 OpenAI 兼容模型列表。 额度走 GET api.minimaxi.com|io /v1/api/openplatform/coding_plan/remains(Bearer)。只解析 model_name=general 的 remaining percent;周窗口仅在 current_weekly_status=1 时计入。 Related to #6742 --- backend/ent/channelmonitor/channelmonitor.go | 3 +- .../channelmonitorrequesttemplate.go | 3 +- backend/ent/migrate/schema.go | 4 +- backend/ent/schema/channel_monitor.go | 2 +- .../channel_monitor_request_template.go | 2 +- backend/ent/schema/user_platform_quota.go | 2 +- backend/internal/config/config.go | 4 + backend/internal/domain/constants.go | 3 +- .../internal/handler/admin/channel_handler.go | 1 + .../handler/admin/channel_handler_test.go | 2 +- .../handler/admin/channel_monitor_handler.go | 4 +- .../internal/handler/admin/group_handler.go | 6 +- .../admin/group_handler_platform_test.go | 4 +- .../handler/composite_platform_test.go | 3 +- backend/internal/handler/gateway_handler.go | 4 +- .../internal/handler/gateway_models_test.go | 3 +- .../openai_gateway_cn_dispatch_test.go | 2 +- .../handler/openai_gateway_handler.go | 2 +- .../internal/model/error_passthrough_rule.go | 2 + .../model/error_passthrough_rule_test.go | 1 + .../account_repo_ollama_cloud_usage.go | 2 +- .../account_repo_ollama_cloud_usage_test.go | 12 +-- .../proxy_repo_upstream_billing_probe_test.go | 4 +- ...er_platform_quota_repo_integration_test.go | 5 +- backend/internal/server/api_contract_test.go | 4 +- backend/internal/server/routes/gateway.go | 6 +- backend/internal/service/account.go | 33 +++++-- .../service/account_header_override.go | 2 +- backend/internal/service/account_service.go | 2 +- backend/internal/service/admin_group.go | 2 +- .../admin_service_composite_group_test.go | 2 +- .../service/channel_monitor_checker.go | 7 +- .../internal/service/channel_monitor_const.go | 5 +- .../channel_monitor_quota_mode_test.go | 9 ++ .../service/channel_monitor_validate.go | 6 +- backend/internal/service/channel_service.go | 2 +- .../internal/service/channel_service_test.go | 2 +- .../cn_provider_balance_check_service.go | 20 ++-- .../cn_provider_balance_check_service_test.go | 9 +- .../service/cn_provider_foraccount_test.go | 1 + .../service/cn_provider_quota_service.go | 95 ++++++++++++++++++- backend/internal/service/cn_providers_test.go | 84 ++++++++++++++++ .../internal/service/composite_platform.go | 7 +- .../service/composite_platform_test.go | 7 +- backend/internal/service/domain_constants.go | 14 ++- .../internal/service/ollama_cloud_usage.go | 2 +- .../service/ollama_cloud_usage_test.go | 2 +- .../service/openai_gateway_cn_fixes_test.go | 4 +- .../service/openai_gateway_scheduling.go | 2 +- .../openai_responses_tool_schema_test.go | 2 +- .../scheduler_snapshot_bulk_event_test.go | 2 +- .../service/scheduler_snapshot_service.go | 6 +- .../service/upstream_billing_probe.go | 2 +- ...stream_billing_probe_multiplatform_test.go | 2 +- .../migrations/235_add_minimax_platform.sql | 60 ++++++++++++ .../minimax_platform_migration_test.go | 21 ++++ docs/COMPOSITE_GROUPS.md | 8 ++ frontend/src/api/admin/channelMonitor.ts | 1 + frontend/src/api/admin/settings.ts | 4 +- .../components/account/AccountUsageCell.vue | 5 +- .../components/account/CnBaseUrlPresets.vue | 2 +- .../components/account/CreateAccountModal.vue | 37 ++++++-- .../components/account/EditAccountModal.vue | 16 ++-- .../account/ModelWhitelistSelector.vue | 3 +- .../__tests__/AccountUsageCell.spec.ts | 2 +- .../__tests__/BulkEditAccountModal.spec.ts | 4 +- .../__tests__/CreateAccountModal.spec.ts | 22 +++++ .../credentialsBuilder.cnAdaptive.spec.ts | 13 ++- .../__tests__/credentialsBuilder.spec.ts | 2 +- .../components/account/credentialsBuilder.ts | 40 ++++++-- .../admin/monitor/MonitorFiltersBar.vue | 2 + .../admin/monitor/MonitorFormDialog.vue | 4 + .../monitor/MonitorTemplateManagerDialog.vue | 2 + frontend/src/components/common/GroupBadge.vue | 8 ++ .../src/components/common/PlatformIcon.vue | 6 ++ .../components/common/PlatformTypeBadge.vue | 18 ++-- .../__tests__/PlatformTypeBadge.grok.spec.ts | 16 ++++ frontend/src/components/keys/UseKeyModal.vue | 20 +++- .../keys/__tests__/UseKeyModal.spec.ts | 2 +- .../user/PlatformUsageBreakdown.vue | 7 +- .../user/dashboard/UserDashboardStats.vue | 7 +- .../components/user/monitor/ProviderIcon.vue | 5 + .../composables/useChannelMonitorFormat.ts | 9 ++ frontend/src/composables/useModelWhitelist.ts | 8 ++ .../src/constants/__tests__/platforms.spec.ts | 3 +- frontend/src/constants/channelMonitor.ts | 3 + frontend/src/constants/platforms.ts | 3 +- .../MonitorSettingsPanel.vue | 4 + .../src/i18n/locales/en/admin/accounts.ts | 1 + .../src/i18n/locales/en/admin/overview.ts | 1 + frontend/src/i18n/locales/en/dashboard.ts | 9 +- .../src/i18n/locales/zh/admin/accounts.ts | 1 + .../src/i18n/locales/zh/admin/overview.ts | 1 + frontend/src/i18n/locales/zh/dashboard.ts | 9 +- frontend/src/types/index.ts | 4 +- frontend/src/utils/platformColors.ts | 16 ++++ frontend/src/views/admin/ChannelsView.vue | 4 +- frontend/src/views/admin/GroupsView.vue | 8 +- frontend/src/views/admin/ProxiesView.vue | 8 ++ .../GroupsView.compositePlatforms.spec.ts | 2 +- .../__tests__/channelPlatformOptions.spec.ts | 1 + 101 files changed, 704 insertions(+), 151 deletions(-) create mode 100644 backend/migrations/235_add_minimax_platform.sql create mode 100644 backend/migrations/minimax_platform_migration_test.go diff --git a/backend/ent/channelmonitor/channelmonitor.go b/backend/ent/channelmonitor/channelmonitor.go index 3224c6eb04..f7dcac62cc 100644 --- a/backend/ent/channelmonitor/channelmonitor.go +++ b/backend/ent/channelmonitor/channelmonitor.go @@ -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) diff --git a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go index f0e9dbf16e..2913cf8da9 100644 --- a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go +++ b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go @@ -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) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 0c08716e1a..345f5f4863 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -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}, diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go index e14416b765..1f0b2713ce 100644 --- a/backend/ent/schema/channel_monitor.go +++ b/backend/ent/schema/channel_monitor.go @@ -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 可空) diff --git a/backend/ent/schema/channel_monitor_request_template.go b/backend/ent/schema/channel_monitor_request_template.go index 332a01007c..80e41c6259 100644 --- a/backend/ent/schema/channel_monitor_request_template.go +++ b/backend/ent/schema/channel_monitor_request_template.go @@ -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). diff --git a/backend/ent/schema/user_platform_quota.go b/backend/ent/schema/user_platform_quota.go index 123d7c0bfa..abe4b2fd8d 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 5d04ab5306..556c19f55f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2047,6 +2047,10 @@ func setDefaults() { "api.moonshot.cn", "open.bigmodel.cn", "api.minimaxi.com", + "api.minimax.cn", + "api.minimax.io", + "www.minimaxi.com", + "www.minimax.io", "generativelanguage.googleapis.com", "cloudcode-pa.googleapis.com", "*.openai.azure.com", diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 3c84890c5b..266bfa49b2 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 (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" // 按入站协议优先选择供应商原生端点 ) diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 2a7052c745..e6612f5a0d 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -643,6 +643,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/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index ef0d5a9c8f..d735c54707 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -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) diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index 4832be2dec..e0fdb46842 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -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"` diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 5befe52bd1..f5895779f8 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -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"` diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go index aeba6d1c5a..38452ec286 100644 --- a/backend/internal/handler/admin/group_handler_platform_test.go +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -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)) diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index d9a642ce06..fa7181c1dd 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -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) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 9e56d57eba..419d5c5362 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1488,6 +1488,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) } @@ -1517,7 +1519,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 386954a17e..ccc7ba3f01 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -801,13 +801,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)) } diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go index eefb0948cb..463feb276c 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) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index c7884c9dd2..18a26f747a 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -321,7 +321,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 diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index bb3a315771..c0cdfc4c6c 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 fc9ff5b385..7b69b1cf21 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/repository/account_repo_ollama_cloud_usage.go b/backend/internal/repository/account_repo_ollama_cloud_usage.go index 55634765a4..d5b506ab7a 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage.go @@ -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' diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go index 4fb3b6cce5..6891eb7b80 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go @@ -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'") diff --git a/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go b/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go index 1f0ea83d34..0de68af464 100644 --- a/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go +++ b/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go @@ -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)")). diff --git a/backend/internal/repository/user_platform_quota_repo_integration_test.go b/backend/internal/repository/user_platform_quota_repo_integration_test.go index 1bd4b08786..c9433adc87 100644 --- a/backend/internal/repository/user_platform_quota_repo_integration_test.go +++ b/backend/internal/repository/user_platform_quota_repo_integration_test.go @@ -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) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 2fe0a1c227..5edbfbb106 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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, diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index fe753b2389..183e462e6b 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 ad1ad82a1b..fb7fee0dd8 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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() } @@ -1582,6 +1595,12 @@ 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"), + a.Platform == PlatformMiniMax: + // MiniMax 额度固定打 api.minimaxi.com / api.minimax.io,自定义中转仍按账号平台路由。 + return PlatformMiniMax default: return "" } diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go index 078d98d832..3118d159fc 100644 --- a/backend/internal/service/account_header_override.go +++ b/backend/internal/service/account_header_override.go @@ -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 c8ae6dc06f..d7fac72223 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -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: diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 662ca14415..d265cd3209 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -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 diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index 5463f9e513..257d001804 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -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) } } diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 23388405a0..404f17e5b8 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -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 diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 6a2b9b92e8..155d6082f8 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -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", diff --git a/backend/internal/service/channel_monitor_quota_mode_test.go b/backend/internal/service/channel_monitor_quota_mode_test.go index 7309d2cf2a..11b102335d 100644 --- a/backend/internal/service/channel_monitor_quota_mode_test.go +++ b/backend/internal/service/channel_monitor_quota_mode_test.go @@ -458,11 +458,20 @@ 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: "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}, diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 6b3b9fd81f..90414943b9 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -219,14 +219,14 @@ func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { // - 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 diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 688b46e7e9..adc313c9b0 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 e443b80b59..1bde091d4d 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_provider_balance_check_service.go b/backend/internal/service/cn_provider_balance_check_service.go index f880605d68..691f8fd33c 100644 --- a/backend/internal/service/cn_provider_balance_check_service.go +++ b/backend/internal/service/cn_provider_balance_check_service.go @@ -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) } } diff --git a/backend/internal/service/cn_provider_balance_check_service_test.go b/backend/internal/service/cn_provider_balance_check_service_test.go index 59eba6d311..825e66ff63 100644 --- a/backend/internal/service/cn_provider_balance_check_service_test.go +++ b/backend/internal/service/cn_provider_balance_check_service_test.go @@ -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 门控不启动的老部署路径)。 diff --git a/backend/internal/service/cn_provider_foraccount_test.go b/backend/internal/service/cn_provider_foraccount_test.go index 664da505fb..17aa7b7a1e 100644 --- a/backend/internal/service/cn_provider_foraccount_test.go +++ b/backend/internal/service/cn_provider_foraccount_test.go @@ -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) { diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go index 74c1f91fd6..a78102c306 100644 --- a/backend/internal/service/cn_provider_quota_service.go +++ b/backend/internal/service/cn_provider_quota_service.go @@ -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 diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 84598f3ba7..6b3341ed76 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -229,6 +229,79 @@ 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()) + require.Empty(t, (&Account{Platform: PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModePayG}}).GetCodingPlanProvider()) +} + // TestCNBalanceURL Kimi 固定端点;DeepSeek 基于 base_url 拼接。 func TestCNBalanceURL(t *testing.T) { t.Parallel() @@ -451,6 +524,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 +562,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 +577,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 +598,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 +657,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{ diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index c7736f9582..2c13468b4e 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,9 @@ 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, "abab"): + return PlatformMiniMax, true default: return "", false } @@ -195,7 +200,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 bff5928024..361eb64e75 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -176,6 +176,9 @@ 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: "unknown k3 alias", model: "k3-preview", ok: false}, {name: "unknown", model: "llama-4-maverick", ok: false}, } @@ -211,13 +214,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 50de7a05d1..f4a13ee1a9 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,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 标识。 diff --git a/backend/internal/service/ollama_cloud_usage.go b/backend/internal/service/ollama_cloud_usage.go index 2956cc9e3b..08f1cbbcc6 100644 --- a/backend/internal/service/ollama_cloud_usage.go +++ b/backend/internal/service/ollama_cloud_usage.go @@ -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 diff --git a/backend/internal/service/ollama_cloud_usage_test.go b/backend/internal/service/ollama_cloud_usage_test.go index 5ad87d69d5..5fcfd7e407 100644 --- a/backend/internal/service/ollama_cloud_usage_test.go +++ b/backend/internal/service/ollama_cloud_usage_test.go @@ -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 diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 576f8bfd42..370ccce8ab 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -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} diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index a90bad3e25..a980195486 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -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 diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index b2dfba46bc..938ab59ab7 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -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) diff --git a/backend/internal/service/scheduler_snapshot_bulk_event_test.go b/backend/internal/service/scheduler_snapshot_bulk_event_test.go index 0f6003a01a..d8fdc063a0 100644 --- a/backend/internal/service/scheduler_snapshot_bulk_event_test.go +++ b/backend/internal/service/scheduler_snapshot_bulk_event_test.go @@ -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}}) diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index e38d0291ba..c307c302ea 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。 diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 5ab821cf2c..145761adf1 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -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 diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 0855c131a9..d76e868052 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -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) diff --git a/backend/migrations/235_add_minimax_platform.sql b/backend/migrations/235_add_minimax_platform.sql new file mode 100644 index 0000000000..308c331ebc --- /dev/null +++ b/backend/migrations/235_add_minimax_platform.sql @@ -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 $$; diff --git a/backend/migrations/minimax_platform_migration_test.go b/backend/migrations/minimax_platform_migration_test.go new file mode 100644 index 0000000000..6e073c7a24 --- /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("235_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'))") +} diff --git a/docs/COMPOSITE_GROUPS.md b/docs/COMPOSITE_GROUPS.md index de6d0cf071..bf32c902bc 100644 --- a/docs/COMPOSITE_GROUPS.md +++ b/docs/COMPOSITE_GROUPS.md @@ -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. diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index bf9e1c88fb..b60f2bbcb2 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -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' diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 3c8d608637..efe6f0402d 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -38,17 +38,19 @@ export type SchedulingThresholdPlatformType = | "grok" | "kimi" | "zhipu" + | "minimax" export type AccountSchedulingThresholdsMap = Record // 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型, -// 走余额检测而非用量阈值)。 +// 走余额检测而非用量阈值;minimax Coding/Token Plan 有 5h/weekly 窗口)。 export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [ "openai", "anthropic", "grok", "kimi", "zhipu", + "minimax", ] export function normalizeAccountSchedulingThresholdsMap( diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 554295f97e..7860009410 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -430,7 +430,7 @@ -