feat(minimax): 增加 MiniMax 平台支持

把 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
This commit is contained in:
刘佳林 committed 2026-09-07 18:22:56 +08:00
1 parent 909b8c7ef4
commit 19382f275e
101 files changed
+704 -151

No files matched your search

+4
View File
@@ -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",
+2 -1
View File
@@ -27,6 +27,7 @@ const (
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
PlatformComposite = "composite"
)
@@ -43,7 +44,7 @@ const (
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi / minimax 原生端点,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
@@ -643,6 +643,7 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -549,7 +549,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
svc := service.NewPricingService(nil, nil)
router := setupSyncPricingModelsRouter(svc)
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} {
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -258,7 +258,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -332,7 +332,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "composite",
"kimi", "zhipu", "deepseek", "minimax", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
@@ -72,7 +72,7 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
}
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
@@ -35,6 +35,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
{model: "k3", platform: service.PlatformKimi},
{model: "glm-5.2", platform: service.PlatformZhipu},
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
{model: "MiniMax-M3", platform: service.PlatformMiniMax},
}
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
for _, provider := range providers {
@@ -56,7 +57,7 @@ func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testi
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
for _, platform := range []string{
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformAnthropic, service.PlatformGemini,
} {
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
+3 -1
View File
@@ -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
@@ -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))
}
@@ -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)
@@ -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
@@ -44,6 +44,7 @@ const (
PlatformKimi = domain.PlatformKimi
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
)
// AllPlatforms 返回所有支持的平台列表
@@ -57,6 +58,7 @@ func AllPlatforms() []string {
PlatformKimi,
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
}
}
@@ -16,5 +16,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"kimi",
"zhipu",
"deepseek",
"minimax",
}, AllPlatforms())
}
@@ -18,7 +18,7 @@ const (
// ollamaCloudUsagePlatformsSQL 是 service.isOllamaCloudUsagePlatform 的 SQL
// 镜像:Ollama Cloud key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台
// 下复用。所有平台白名单 SQL 只允许引用本常量,不得各处重写字面量,防止漂移。
ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'"
ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'"
ollamaCloudUsageEligibleSQL = `
platform IN (` + ollamaCloudUsagePlatformsSQL + `)
AND type = 'apikey'
@@ -179,7 +179,7 @@ func TestListOllamaCloudUsageGroupAccountsUsesOneStrictBatchQuery(t *testing.T)
require.Empty(t, accounts)
query := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, query, "credentials ->> 'api_key' = ANY($1)")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek')")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')")
require.Contains(t, query, "jsonb_typeof(credentials -> 'api_key') = 'string'")
require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
require.NotContains(t, query, "~*")
@@ -207,7 +207,7 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) {
for _, clause := range []string{
"deleted_at IS NULL",
"status = 'active'",
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek')",
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')",
"type = 'apikey'",
ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"),
"jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'",
@@ -251,7 +251,7 @@ func TestBulkUpdateOllamaIdentityCleanupIsValueConditional(t *testing.T) {
require.Contains(t, query, "NOT ("+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'"))
require.NotContains(t, query, "~*")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek') AND type = 'apikey'")
require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey'")
require.Contains(t, query, "- 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
payload, ok := exec.execArgs[0][0].([]byte)
require.True(t, ok)
@@ -326,10 +326,10 @@ func TestOllamaCloudUsagePlatformWhitelistMatchesServicePredicate(t *testing.T)
for _, match := range matches {
sqlPlatforms[match[1]] = struct{}{}
}
require.Len(t, sqlPlatforms, 5)
require.Len(t, sqlPlatforms, 6)
for _, platform := range []string{
service.PlatformOpenAI, service.PlatformAnthropic,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformGemini, service.PlatformGrok, service.PlatformAntigravity,
service.PlatformComposite, "kiro",
} {
@@ -373,7 +373,7 @@ func TestUpdateCredentialsPlainCNAPIKeyAccountCleanupStaysSemanticallyEquivalent
require.NoError(t, err)
query := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, query,
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb")
"platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb")
require.Contains(t, query,
"THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
require.NotContains(t, query, "- 'upstream_billing_probe_enabled'")
@@ -33,7 +33,7 @@ func TestProxyUpdateInvalidatesBoundProbeSnapshotsAndEnqueuesOutboxAtomically(t
WithArgs(int64(9)).
WillReturnResult(sqlmock.NewResult(0, 0))
expectProxyUpdateReload(mock, 9, "new.example", "user", "pass")
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
WithArgs(int64(9)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)).AddRow(int64(18)))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
@@ -76,7 +76,7 @@ func TestProxyUpdateRollsBackWhenProbeInvalidationOutboxFails(t *testing.T) {
WithArgs(int64(9)).
WillReturnResult(sqlmock.NewResult(0, 0))
expectProxyUpdateReload(mock, 9, "new.example", "", "")
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
WithArgs(int64(9)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
@@ -116,11 +116,12 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed(t *tes
{UserID: userID, Platform: "kimi", DailyLimitUSD: &daily},
{UserID: userID, Platform: "zhipu"},
{UserID: userID, Platform: "deepseek"},
{UserID: userID, Platform: "minimax"},
}
require.NoError(t, repo.BulkInsertInitial(txCtx, records),
"kimi/zhipu/deepseek 平台应可写入(迁移 224 后 CHECK 约束已含国产供应商)")
"kimi/zhipu/deepseek/minimax 平台应可写入(CHECK 约束已含国产供应商)")
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
rec, err := repo.GetByUserPlatform(txCtx, userID, platform)
require.NoError(t, err)
require.NotNil(t, rec, "%s 配额行应已写入", platform)
+2 -2
View File
@@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -1180,7 +1180,7 @@ func TestAPIContracts(t *testing.T) {
"purchase_subscription_url": "",
"table_default_page_size": 20,
"table_page_size_options": [10, 20, 50],
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
+3 -3
View File
@@ -48,8 +48,8 @@ func RegisterGatewayRoutes(
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 openai/grok 一样经 OpenAI 网关转发。
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax:
// 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。
return true
default:
return false
@@ -57,7 +57,7 @@ func RegisterGatewayRoutes(
}
countTokensHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax:
h.OpenAIGateway.CountTokens(c)
case service.PlatformGrok:
h.OpenAIGateway.GrokCountTokens(c)
+26 -7
View File
@@ -285,17 +285,20 @@ func (a *Account) IsDeepseek() bool {
return a.Platform == PlatformDeepseek
}
// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
func (a *Account) IsMiniMax() bool {
return a.Platform == PlatformMiniMax
}
// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。
func (a *Account) IsCNProvider() bool {
return a != nil && IsCNProvider(a.Platform)
}
// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
// openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions
// openai/grok 原生走 OpenAI 网关;国产供应商同为 OpenAI Chat Completions
// 兼容上游,也经 OpenAI 网关转发。
func (a *Account) IsOpenAICompatible() bool {
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok ||
a.Platform == PlatformKimi || a.Platform == PlatformZhipu || a.Platform == PlatformDeepseek)
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider())
}
func (a *Account) GeminiOAuthType() string {
@@ -1362,6 +1365,8 @@ func (a *Account) GetOpenAIBaseURL() string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
default:
return "https://api.openai.com"
}
@@ -1387,7 +1392,7 @@ func (a *Account) IsCodingPlan() bool {
// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi / minimax 支持(官方原生
// Responses 端点,适配 Codex);zhipu 无此端点。
func (a *Account) GetAPIProtocol() string {
if a == nil || !a.IsCNProvider() {
@@ -1410,13 +1415,13 @@ func (a *Account) GetAPIProtocol() string {
// SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。
// DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为
// /v1/responses(moonshot.cn / kimi.com/coding)。
// /v1/responses(moonshot.cn / kimi.com/coding);MiniMax 为 /v1/responses。
func (a *Account) SupportsNativeCNResponses() bool {
if a == nil {
return false
}
switch a.Platform {
case PlatformDeepseek, PlatformKimi:
case PlatformDeepseek, PlatformKimi, PlatformMiniMax:
return true
default:
return false
@@ -1477,6 +1482,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
case PlatformMiniMax:
return DefaultMiniMaxAnthropicBaseURL
}
case APIProtocolChatCompletions, APIProtocolResponses:
switch a.Platform {
@@ -1492,6 +1499,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
}
}
return ""
@@ -1528,6 +1537,8 @@ func (a *Account) GetAnthropicProtocolBaseURL() string {
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
case PlatformMiniMax:
return DefaultMiniMaxAnthropicBaseURL
default:
return ""
}
@@ -1555,6 +1566,8 @@ func (a *Account) GetOpenAIFormatBaseURL() string {
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
default:
return a.GetOpenAIBaseURL()
}
@@ -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 ""
}
@@ -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
+1 -1
View File
@@ -515,7 +515,7 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
case PlatformGrok:
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
return nil
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
// 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
return nil
default:
+1 -1
View File
@@ -314,7 +314,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
func compositeDefaultModelsListCandidateIDs() []string {
seen := make(map[string]struct{})
ids := make([]string, 0)
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
for _, id := range defaultModelsListCandidateIDs(platform) {
if _, ok := seen[id]; ok {
continue
@@ -213,7 +213,7 @@ func TestAdminService_CNProviderModelsListCandidatesKeepClaudeDefaults(t *testin
for _, model := range claude.DefaultModels {
want = append(want, model.ID)
}
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
require.Equal(t, want, defaultModelsListCandidateIDs(platform), "platform=%s", platform)
}
}
@@ -175,6 +175,7 @@ var providerAdapters = map[string]providerAdapter{
MonitorProviderKimi: providerKimiChatAdapter,
MonitorProviderZhipu: providerZhipuChatAdapter,
MonitorProviderDeepseek: providerDeepseekChatAdapter,
MonitorProviderMiniMax: providerMiniMaxChatAdapter,
MonitorProviderAnthropic: {
buildPath: func(string) string { return providerAnthropicPath },
buildBody: func(model, prompt string) ([]byte, error) {
@@ -226,6 +227,9 @@ var providerZhipuChatAdapter = newOpenAICompatibleChatAdapter(providerZhipuPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerDeepseekChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerMiniMaxChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
func newOpenAICompatibleChatAdapter(path string) providerAdapter {
return providerAdapter{
buildPath: func(string) string { return path },
@@ -458,6 +462,7 @@ var bodyMergeKeyDenyList = map[string]map[string]bool{
MonitorProviderKimi: {"model": true, "messages": true, "stream": true},
MonitorProviderZhipu: {"model": true, "messages": true, "stream": true},
MonitorProviderDeepseek: {"model": true, "messages": true, "stream": true},
MonitorProviderMiniMax: {"model": true, "messages": true, "stream": true},
}
func checkAPIMode(opts *CheckOptions) string {
@@ -479,7 +484,7 @@ func bodyMergeDenyKey(provider, apiMode string) string {
func isOpenAICompatibleChatProvider(provider string) bool {
switch provider {
case MonitorProviderOpenAI, MonitorProviderGrok,
MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek:
MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, MonitorProviderMiniMax:
return true
default:
return false
@@ -69,6 +69,7 @@ const (
MonitorProviderKimi = "kimi"
MonitorProviderZhipu = "zhipu"
MonitorProviderDeepseek = "deepseek"
MonitorProviderMiniMax = "minimax"
// MonitorCheckMode 检测模式(channel_monitors.check_mode)。
// probe - LLM 探活(默认,原有行为)
@@ -151,7 +152,7 @@ var (
"CHANNEL_MONITOR_NOT_FOUND", "channel monitor not found",
)
ErrChannelMonitorInvalidProvider = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek",
"CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek/minimax",
)
ErrChannelMonitorInvalidCheckMode = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_CHECK_MODE", "check_mode must be one of probe/quota/quota_probe; antigravity only supports quota",
@@ -163,7 +164,7 @@ var (
"CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform",
)
ErrChannelMonitorAccountNotSupportable = infraerrors.BadRequest(
"CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)",
"CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu/minimax, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)",
)
ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai",
@@ -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},
@@ -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
+1 -1
View File
@@ -357,7 +357,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool {
// fallback used before a request target has been resolved.
func matchingPlatforms(groupPlatform string) []string {
if groupPlatform == PlatformComposite {
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
}
return []string{groupPlatform}
}
@@ -2069,7 +2069,7 @@ func TestMatchingPlatforms(t *testing.T) {
{"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}},
{"gemini returns itself", PlatformGemini, []string{PlatformGemini}},
{"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}},
}
for _, tt := range tests {
@@ -126,9 +126,9 @@ func (s *CNProviderBalanceCheckService) runOnce() {
quotaTargets = append(quotaTargets, quotaTarget{id: account.ID, platform: account.Platform})
continue
}
// payg 余额探测仅 kimi/deepseek(智谱无公开余额端点,payg 账号
// 依赖响应式 402/429 处理)。
if platform != PlatformZhipu && account.Schedulable {
// payg 余额探测仅 kimi/deepseek(智谱 / MiniMax 无公开余额端点,
// payg 账号依赖响应式 402/429 处理)。
if platform != PlatformZhipu && platform != PlatformMiniMax && account.Schedulable {
paygTargets = append(paygTargets, account)
}
}
@@ -141,13 +141,15 @@ func (s *CNProviderBalanceCheckService) runOnce() {
}
collect(platform, accounts)
}
// 智谱无余额端点,仅进额度探测。
// 智谱 / MiniMax 无余额端点,仅进额度探测。
if s.quotaService != nil {
accounts, err := s.accountRepo.ListByPlatform(context.Background(), PlatformZhipu)
if err != nil {
log.Printf("[CNBalance] list %s accounts failed: %v", PlatformZhipu, err)
} else {
collect(PlatformZhipu, accounts)
for _, platform := range []string{PlatformZhipu, PlatformMiniMax} {
accounts, err := s.accountRepo.ListByPlatform(context.Background(), platform)
if err != nil {
log.Printf("[CNBalance] list %s accounts failed: %v", platform, err)
continue
}
collect(platform, accounts)
}
}
@@ -49,10 +49,13 @@ func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) {
Credentials: map[string]any{"account_mode": "coding"}}
zhipuCoding := Account{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive,
Credentials: map[string]any{"account_mode": "coding"}}
minimaxCoding := Account{ID: 5, Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Status: StatusActive,
Credentials: map[string]any{"account_mode": "coding"}}
repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{
PlatformKimi: {kimiActive, kimiPaused, kimiInactive},
PlatformZhipu: {zhipuCoding},
PlatformKimi: {kimiActive, kimiPaused, kimiInactive},
PlatformZhipu: {zhipuCoding},
PlatformMiniMax: {minimaxCoding},
}}
prober := &fakeCNQuotaProber{}
svc := &CNProviderBalanceCheckService{
@@ -63,7 +66,7 @@ func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) {
svc.runOnce()
require.ElementsMatch(t, []int64{1, 2, 4}, prober.probed)
require.ElementsMatch(t, []int64{1, 2, 4, 5}, prober.probed)
}
// runOnceZhipuQuota 在 quotaService 缺失时安全跳过(Start 门控不启动的老部署路径)。
@@ -46,6 +46,7 @@ func TestValidateCodingPlanAccount_Matrix(t *testing.T) {
{name: "payg has no quota endpoint", account: paygAccount(PlatformKimi), wantReason: "CN_QUOTA_NOT_CODING_PLAN"},
{name: "kimi coding ok", account: codingAccount(PlatformKimi)},
{name: "zhipu coding ok", account: codingAccount(PlatformZhipu)},
{name: "minimax coding ok", account: codingAccount(PlatformMiniMax)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -129,8 +129,8 @@ func (s *CNProviderQuotaService) QueryUsageForAccount(ctx context.Context, accou
func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) {
provider := account.GetCodingPlanProvider()
if provider != PlatformKimi && provider != PlatformZhipu {
return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu coding plan account")
if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax {
return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu/minimax coding plan account")
}
apiKey := strings.TrimSpace(account.GetCNAPIKey())
@@ -158,6 +158,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
if zhipuOrg != "" {
targetURL += "?type=2"
}
case PlatformMiniMax:
targetURL = minimaxQuotaURL(baseURL)
authHeader = "Bearer " + apiKey
}
// 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验):
@@ -177,7 +180,7 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
}
req.Header.Set("Authorization", authHeader)
req.Header.Set("Accept", "application/json")
if provider == PlatformZhipu {
if provider == PlatformZhipu || provider == PlatformMiniMax {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "en-US,en")
if zhipuOrg != "" {
@@ -234,6 +237,17 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
case PlatformZhipu:
tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data"))
result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String())
case PlatformMiniMax:
if status := gjson.GetBytes(bodyBytes, "base_resp.status_code"); status.Exists() && status.Int() != 0 {
msg := strings.TrimSpace(gjson.GetBytes(bodyBytes, "base_resp.status_msg").String())
if msg == "" {
msg = "unknown minimax quota error"
}
result.Error = fmt.Sprintf("API error (%d): %s", status.Int(), msg)
return result, nil
}
tiers = parseMiniMaxUsageTiers(bodyBytes)
result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "current_subscribe_title").String())
}
result.Tiers = tiers
result.Success = true
@@ -305,6 +319,16 @@ func kimiQuotaURL(baseURL string) string {
return base + "/v1/usages"
}
// minimaxQuotaURL 根据推理域名选择 Token Plan / Coding Plan 额度主机。
// 官方 FAQ 写 www.minimax.io / www.minimaxi.com,实际以 Bearer Key 打 api.*。
// 国际站 api.minimax.io;国内站 api.minimaxi.com(含 api.minimax.com 与自定义回落)。
func minimaxQuotaURL(baseURL string) string {
if strings.Contains(strings.ToLower(baseURL), "minimax.io") {
return "https://api.minimax.io/v1/api/openplatform/coding_plan/remains"
}
return "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains"
}
func zhipuQuotaHost(baseURL string) string {
switch u := strings.ToLower(baseURL); {
case strings.Contains(u, "bigmodel.cn"):
@@ -372,6 +396,71 @@ func parseKimiUsageTiers(body []byte) []CNQuotaTier {
return tiers
}
// parseMiniMaxUsageTiers 解析 MiniMax Token Plan / Coding Plan remains 响应。
//
// 只取 model_name == "general"(编程套餐),跳过 video。字段是剩余百分比,
// 展示已用 = 100 - remaining:
// - 5h:current_interval_remaining_percent + end_time
// - 周限额:仅 current_weekly_status == 1 时用 current_weekly_remaining_percent + weekly_end_time
func parseMiniMaxUsageTiers(body []byte) []CNQuotaTier {
remains := gjson.GetBytes(body, "model_remains")
if !remains.IsArray() {
return nil
}
var general gjson.Result
remains.ForEach(func(_, item gjson.Result) bool {
if strings.EqualFold(strings.TrimSpace(item.Get("model_name").String()), "general") {
general = item
return false
}
return true
})
if !general.Exists() {
return nil
}
var tiers []CNQuotaTier
if remaining, ok := cnParseF64(general.Get("current_interval_remaining_percent").Value()); ok {
used := 100 - remaining
if used < 0 {
used = 0
}
tiers = append(tiers, CNQuotaTier{
Window: "5h",
UsedPercent: used,
ResetAt: minimaxResetTime(general.Get("end_time")),
})
}
if general.Get("current_weekly_status").Int() == 1 {
if remaining, ok := cnParseF64(general.Get("current_weekly_remaining_percent").Value()); ok {
used := 100 - remaining
if used < 0 {
used = 0
}
tiers = append(tiers, CNQuotaTier{
Window: "weekly",
UsedPercent: used,
ResetAt: minimaxResetTime(general.Get("weekly_end_time")),
})
}
}
return tiers
}
func minimaxResetTime(v gjson.Result) string {
if !v.Exists() {
return ""
}
ms := v.Int()
if ms <= 0 {
return cnNormalizeResetTime(v.Value())
}
if ms < 1_000_000_000_000 {
return time.Unix(ms, 0).UTC().Format(time.RFC3339)
}
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}
// cnZhipuWindow 标识智谱 TOKENS_LIMIT 条目所属窗口。
type cnZhipuWindow int
@@ -229,6 +229,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{
@@ -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
@@ -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))
}
+10 -4
View File
@@ -47,6 +47,7 @@ const (
PlatformKimi = domain.PlatformKimi
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformComposite = domain.PlatformComposite
// PlatformKiro is retained for unsupported-platform threshold tests and legacy
// account rows. Scheduling-threshold evaluation never pauses kiro accounts.
@@ -75,6 +76,8 @@ const (
DefaultZhipuPayGBaseURL = "https://open.bigmodel.cn/api/paas/v4"
DefaultZhipuCodingBaseURL = "https://open.bigmodel.cn/api/coding/paas/v4"
DefaultDeepseekBaseURL = "https://api.deepseek.com"
// MiniMax 按量付费与 Coding/Token Plan 共用推理域名,靠 API Key 区分套餐。
DefaultMiniMaxBaseURL = "https://api.minimaxi.com/v1"
)
// 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。
@@ -84,12 +87,13 @@ const (
DefaultKimiCodingAnthropicBaseURL = "https://api.kimi.com/coding"
DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic"
DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic"
DefaultMiniMaxAnthropicBaseURL = "https://api.minimaxi.com/anthropic"
)
// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。
func IsCNProvider(platform string) bool {
switch platform {
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return true
default:
return false
@@ -108,17 +112,19 @@ var AllowedQuotaPlatforms = []string{
PlatformKimi,
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
}
// AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。
// openai/anthropic/grok 有原生用量窗口;kimi/zhipu 的 Coding Plan 同样暴露 5h/weekly
// 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。
// openai/anthropic/grok 有原生用量窗口;kimi/zhipu/minimax 的 Coding Plan 同样暴露
// 5h/weekly 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。
var AllowedSchedulingThresholdPlatforms = []string{
PlatformOpenAI,
PlatformAnthropic,
PlatformGrok,
PlatformKimi,
PlatformZhipu,
PlatformMiniMax,
}
// IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。
@@ -1014,7 +1014,7 @@ func OllamaCloudUsageStateFromAccount(account *Account) *OllamaCloudUsageState {
// (ollamaCloudUsagePlatformsSQL)是本列表的镜像,两侧必须同步修改。
func isOllamaCloudUsagePlatform(platform string) bool {
switch platform {
case PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return true
default:
return false
@@ -497,7 +497,7 @@ func TestIsOllamaCloudUsageAccountStrictOfficialHost(t *testing.T) {
// oauth 类型账号即使平台与 base_url 都命中也不进用量窗口(仅 apikey 账号)。
func TestIsOllamaCloudUsageAccountRejectsOAuthType(t *testing.T) {
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
account := ollamaUsageAccount(1)
account.Platform = platform
account.Type = AccountTypeOAuth
@@ -24,7 +24,7 @@ import (
)
func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
g := &Group{Platform: platform}
require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"),
"CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform)
@@ -104,7 +104,7 @@ func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *te
}
func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := &Account{ID: 401, Platform: platform, Type: AccountTypeAPIKey}
@@ -292,7 +292,7 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount(
// handler 调度入口仍需导出,保持导出名。)
func NormalizeOpenAICompatiblePlatform(platform string) string {
switch platform {
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek:
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return platform
default:
return PlatformOpenAI
@@ -270,7 +270,7 @@ func TestSanitizeOpenAIResponsesToolSchemasForPlatform_ReplayBoundary(t *testing
// A malformed tool definition may be replayed after account failover. Every
// compatible account must repair it, while non-OpenAI providers retain their
// supported regex semantics.
for _, platform := range []string{PlatformAnthropic, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformAnthropic, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
t.Run(platform, func(t *testing.T) {
for attempt := 0; attempt < 2; attempt++ {
normalized, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, platform)
@@ -115,7 +115,7 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing.
}
func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
t.Run(platform, func(t *testing.T) {
cache := newBulkEventSnapshotCache()
repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}})
@@ -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。
@@ -1013,7 +1013,7 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool {
}
switch platform {
case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek:
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return true
default:
return false
@@ -16,7 +16,7 @@ import (
func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) {
for _, platform := range []string{
PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity,
PlatformKimi, PlatformZhipu, PlatformDeepseek,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax,
} {
require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform)
require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform)