diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 556c19f55f..95ba702ffa 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2046,11 +2046,8 @@ func setDefaults() { "api.moonshot.ai", "api.moonshot.cn", "open.bigmodel.cn", - "api.minimaxi.com", - "api.minimax.cn", - "api.minimax.io", - "www.minimaxi.com", - "www.minimax.io", + "api.minimaxi.com", // MiniMax CN quota + inference + "api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site "generativelanguage.googleapis.com", "cloudcode-pa.googleapis.com", "*.openai.azure.com", diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 419d5c5362..7684e69f71 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1276,7 +1276,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 的 diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index ccc7ba3f01..236fe7e1ee 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -692,6 +692,13 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t "model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"}, }, }, + { + ID: 7, + Platform: service.PlatformMiniMax, + Credentials: map[string]any{ + "model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"}, + }, + }, }, }, }, @@ -706,7 +713,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t Platform: service.PlatformComposite, ModelsListConfig: service.GroupModelsListConfig{ Enabled: true, - Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, + Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, }, }, }) @@ -717,7 +724,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t var got gatewayModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data)) + require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data)) } func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) { @@ -770,6 +777,7 @@ func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testin {ID: 2, Platform: service.PlatformKimi}, {ID: 3, Platform: service.PlatformZhipu}, {ID: 4, Platform: service.PlatformDeepseek}, + {ID: 5, Platform: service.PlatformMiniMax}, }, }, }, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index fb7fee0dd8..f7a4ae46cf 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1582,9 +1582,9 @@ func (a *Account) GetCNAPIKey() string { return a.GetCredential("api_key") } -// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu), +// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu / minimax), // 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。 -// 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。 +// 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。 func (a *Account) GetCodingPlanProvider() string { if a == nil || a.GetAccountMode() != AccountModeCoding { return "" @@ -1597,9 +1597,7 @@ func (a *Account) GetCodingPlanProvider() string { 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,自定义中转仍按账号平台路由。 + strings.Contains(baseURL, "minimax.com"): return PlatformMiniMax default: return "" diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index f8b245c51c..fb0696bed8 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -58,10 +58,8 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string] winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now) case PlatformGrok: winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now) - case PlatformKimi: - winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformKimi), threshold, now) - case PlatformZhipu: - winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformZhipu), threshold, now) + case PlatformKimi, PlatformZhipu, PlatformMiniMax: + winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now) default: return decision } diff --git a/backend/internal/service/channel_monitor_quota_fetcher.go b/backend/internal/service/channel_monitor_quota_fetcher.go index f8157ddd87..4272ea640d 100644 --- a/backend/internal/service/channel_monitor_quota_fetcher.go +++ b/backend/internal/service/channel_monitor_quota_fetcher.go @@ -216,7 +216,7 @@ func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountI // (GetUsageForAccount / QueryUsageForAccount / QueryBalanceForAccount), // 下游服务不再各自 GetByID(每次含 proxies/groups 联查)。 switch account.Platform { - case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek: + case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek, domain.PlatformMiniMax: if account.IsCodingPlan() { return f.fetchCNQuota(ctx, account, now) } diff --git a/backend/internal/service/channel_monitor_quota_fetcher_test.go b/backend/internal/service/channel_monitor_quota_fetcher_test.go index 822009a17e..b03b9fe33d 100644 --- a/backend/internal/service/channel_monitor_quota_fetcher_test.go +++ b/backend/internal/service/channel_monitor_quota_fetcher_test.go @@ -173,6 +173,30 @@ func TestQuotaFetcher_CodingPlanAccountUsesCNQuota(t *testing.T) { require.Equal(t, 0, cnBalance.calls) } +func TestQuotaFetcher_MiniMaxCodingPlanUsesCNQuota(t *testing.T) { + fetcher, usage, cnQuota, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[19] = &Account{ + ID: 19, + Platform: domain.PlatformMiniMax, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{ + Success: true, + CredentialValid: true, + Tiers: []CNQuotaTier{ + {Window: "5h", UsedPercent: 12}, + }, + } + + snapshot := fetcher.Fetch(context.Background(), 19) + + require.True(t, snapshot.Success) + require.Equal(t, "cn_quota", snapshot.Source) + require.Equal(t, 1, cnQuota.calls) + require.Equal(t, 0, cnBalance.calls) + require.Equal(t, 0, usage.getCalls()) +} + func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) { fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) accounts.accounts[11] = &Account{ diff --git a/backend/internal/service/channel_monitor_quota_mode_test.go b/backend/internal/service/channel_monitor_quota_mode_test.go index 11b102335d..b4ca4dae29 100644 --- a/backend/internal/service/channel_monitor_quota_mode_test.go +++ b/backend/internal/service/channel_monitor_quota_mode_test.go @@ -462,6 +462,12 @@ func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) { name: "minimax coding default endpoint ok", account: &Account{ID: 14, Platform: domain.PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModeCoding}}, }, + { + name: "custom-domain minimax coding unsupported", + account: &Account{ID: 16, Platform: domain.PlatformMiniMax, Type: AccountTypeAPIKey, + Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://relay.example.com/v1"}}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, { name: "zhipu payg has no balance endpoint", account: &Account{ID: 5, Platform: domain.PlatformZhipu}, diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 90414943b9..1ca6dcb701 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -211,9 +211,9 @@ func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { // monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与 // fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 / // 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合: -// - kimi/zhipu/deepseek coding:GetCodingPlanProvider 须识别为 kimi/zhipu -// (deepseek coding、自定义域名 kimi coding 无法路由额度端点) -// - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无) +// - kimi/zhipu/deepseek/minimax coding:GetCodingPlanProvider 须识别官方域名 +// (deepseek coding、自定义中转、minimax payg 无法路由额度端点) +// - kimi/zhipu/deepseek/minimax payg:仅 kimi/deepseek 有公开余额端点 // - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error) // - openai:OAuth(API-Key 型无 usage 通道) // - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行 diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 6b3341ed76..8f9e7b8996 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -299,7 +299,16 @@ func TestGetCodingPlanProvider_MiniMax(t *testing.T) { "base_url": "https://api.minimax.io/anthropic", }} require.Equal(t, PlatformMiniMax, intl.GetCodingPlanProvider()) + // 未配 base_url 时走国内站默认域名,仍能识别官方额度端点。 + require.Equal(t, PlatformMiniMax, (&Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + }}).GetCodingPlanProvider()) require.Empty(t, (&Account{Platform: PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModePayG}}).GetCodingPlanProvider()) + // 自定义中转不得把第三方 Key 发往官方额度端点。 + require.Empty(t, (&Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "base_url": "https://relay.example.com/v1", + }}).GetCodingPlanProvider()) } // TestCNBalanceURL Kimi 固定端点;DeepSeek 基于 base_url 拼接。 @@ -382,6 +391,28 @@ func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) { require.True(t, reset.Equal(*decision.Until)) } +func TestEvaluateAccountSchedulingThreshold_MiniMaxCodingPlan(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + reset := now.Add(3 * time.Hour) + account := &Account{ + Platform: PlatformMiniMax, + Extra: map[string]any{ + "minimax_5h_used_percent": 90.0, + "minimax_5h_reset_at": reset.Format(time.RFC3339), + "minimax_weekly_used_percent": 30.0, + "minimax_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339), + }, + } + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformMiniMax: 80}, now) + require.True(t, decision.ShouldPause) + require.Equal(t, PlatformMiniMax, decision.Platform) + require.Equal(t, "5h", decision.Window) + require.InDelta(t, 90.0, decision.UsedPercent, 1e-9) + require.NotNil(t, decision.Until) + require.True(t, reset.Equal(*decision.Until)) +} + // TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped 窗口已重置(reset<=now) // 或用量低于阈值 → 不停调(candidateMatchesThreshold 要求 until.After(now))。 func TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped(t *testing.T) { diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index 2c13468b4e..fc44428039 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -151,7 +151,9 @@ func DetectModelPlatform(model string) (string, bool) { case strings.HasPrefix(normalized, "deepseek-"): return PlatformDeepseek, true case strings.HasPrefix(normalized, "minimax-"), - strings.HasPrefix(normalized, "abab"): + strings.HasPrefix(normalized, "abab5"), + strings.HasPrefix(normalized, "abab6"), + strings.HasPrefix(normalized, "abab7"): return PlatformMiniMax, true default: return "", false diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 361eb64e75..2ca27c5951 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -179,6 +179,8 @@ func TestDetectModelPlatform(t *testing.T) { {name: "minimax", model: "MiniMax-M3", platform: PlatformMiniMax, ok: true}, {name: "minimax prefix", model: "minimax/MiniMax-M2.5", platform: PlatformMiniMax, ok: true}, {name: "abab legacy", model: "abab6.5-chat", platform: PlatformMiniMax, ok: true}, + {name: "abab7 legacy", model: "abab7-chat-preview", platform: PlatformMiniMax, ok: true}, + {name: "abab unrelated namespace", model: "abab-other", ok: false}, {name: "unknown k3 alias", model: "k3-preview", ok: false}, {name: "unknown", model: "llama-4-maverick", ok: false}, } diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index c7cbc7c0cc..44272c6bed 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -256,6 +256,7 @@ func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, PlatformKimi, PlatformZhipu, PlatformDeepseek, + PlatformMiniMax, }, false, ) diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 52ef9aa460..8512bdc8d1 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1013,6 +1013,7 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { require.Contains(t, repo.platforms, PlatformOpenAI) require.Contains(t, repo.platforms, PlatformGrok) require.Contains(t, repo.platforms, PlatformDeepseek) + require.Contains(t, repo.platforms, PlatformMiniMax) require.NotContains(t, repo.platforms, PlatformComposite) } diff --git a/backend/migrations/235_add_minimax_platform.sql b/backend/migrations/237_add_minimax_platform.sql similarity index 100% rename from backend/migrations/235_add_minimax_platform.sql rename to backend/migrations/237_add_minimax_platform.sql diff --git a/backend/migrations/minimax_platform_migration_test.go b/backend/migrations/minimax_platform_migration_test.go index 6e073c7a24..20cb0f773a 100644 --- a/backend/migrations/minimax_platform_migration_test.go +++ b/backend/migrations/minimax_platform_migration_test.go @@ -8,7 +8,7 @@ import ( ) func TestMiniMaxPlatformMigration(t *testing.T) { - content, err := FS.ReadFile("235_add_minimax_platform.sql") + content, err := FS.ReadFile("237_add_minimax_platform.sql") require.NoError(t, err) sql := strings.Join(strings.Fields(string(content)), " ")