diff --git a/backend/go.mod b/backend/go.mod index 941f519d08..1c735301d6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -19,6 +19,7 @@ require ( github.com/coder/websocket v1.8.14 github.com/dgraph-io/ristretto v0.2.0 github.com/gin-gonic/gin v1.9.1 + github.com/go-playground/validator/v10 v10.14.0 github.com/go-webauthn/webauthn v0.17.4 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 @@ -120,7 +121,6 @@ require ( github.com/go-openapi/inflect v0.19.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect github.com/goccy/go-json v0.10.2 // indirect diff --git a/backend/internal/domain/platforms.go b/backend/internal/domain/platforms.go new file mode 100644 index 0000000000..f84801ad6b --- /dev/null +++ b/backend/internal/domain/platforms.go @@ -0,0 +1,135 @@ +package domain + +// 平台清单:平台的单一数据来源。"所有平台"类列表(分组平台、用户额度、组合 +// 路由目标、调度快照、定价匹配等)与"某网关族"类列表都从这里派生;新增平台 +// 登记一条即可在这些位置生效,无需逐处修改或新增数据库约束迁移。 +// +// 依赖具体实现的能力(OAuth、渠道监控、余额 / Coding Plan 探测、用量窗口、 +// Codex 模型目录等)仍由各自的代码显式列出,不在此处声明。 + +// PlatformGateway 标识平台请求由哪个网关服务处理。 +type PlatformGateway string + +const ( + // PlatformGatewayAnthropic 由 GatewayService 处理(Claude Messages 协议,以及 TypeSafe 的 /v1/systemone)。 + PlatformGatewayAnthropic PlatformGateway = "anthropic" + // PlatformGatewayOpenAI 由 OpenAIGatewayService 处理(OpenAI、Grok 与多协议 API Key 供应商)。 + PlatformGatewayOpenAI PlatformGateway = "openai" + // PlatformGatewayGemini 由 Gemini 兼容网关处理。 + PlatformGatewayGemini PlatformGateway = "gemini" + // PlatformGatewayAntigravity 由 Antigravity 网关处理。 + PlatformGatewayAntigravity PlatformGateway = "antigravity" +) + +// PlatformSpec 描述一个具体(非 composite)平台。 +type PlatformSpec struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Gateway PlatformGateway `json:"gateway"` + // CNProvider 标识国产单一厂商(Kimi / 智谱 / DeepSeek / MiniMax),共享国产 + // 供应商的余额 / Coding Plan 等能力。 + CNProvider bool `json:"cn_provider"` + // LiteLLMProvider 是定价目录中的 provider 名;空表示不支持从定价目录同步模型。 + LiteLLMProvider string `json:"litellm_provider,omitempty"` +} + +// platformList 的顺序即平台的展示顺序。 +var platformList = []PlatformSpec{ + {ID: PlatformAnthropic, DisplayName: "Anthropic", Gateway: PlatformGatewayAnthropic, LiteLLMProvider: "anthropic"}, + {ID: PlatformOpenAI, DisplayName: "OpenAI", Gateway: PlatformGatewayOpenAI, LiteLLMProvider: "openai"}, + {ID: PlatformGemini, DisplayName: "Gemini", Gateway: PlatformGatewayGemini, LiteLLMProvider: "gemini"}, + {ID: PlatformAntigravity, DisplayName: "Antigravity", Gateway: PlatformGatewayAntigravity, LiteLLMProvider: "anthropic"}, + {ID: PlatformGrok, DisplayName: "Grok", Gateway: PlatformGatewayOpenAI, LiteLLMProvider: "xai"}, + {ID: PlatformKimi, DisplayName: "Kimi", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "moonshot"}, + {ID: PlatformZhipu, DisplayName: "Zhipu GLM", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "zhipu"}, + {ID: PlatformDeepseek, DisplayName: "DeepSeek", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "deepseek"}, + {ID: PlatformMiniMax, DisplayName: "MiniMax", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "minimax"}, + {ID: PlatformOpenCodeGo, DisplayName: "OpenCode", Gateway: PlatformGatewayOpenAI, LiteLLMProvider: "opencode-go"}, + {ID: PlatformTypeSafe, DisplayName: "TypeSafe / Jev", Gateway: PlatformGatewayAnthropic, LiteLLMProvider: "typesafe"}, +} + +var platformIndex = func() map[string]int { + index := make(map[string]int, len(platformList)) + for i, spec := range platformList { + index[spec.ID] = i + } + return index +}() + +// Platforms 返回全部具体平台(副本),按展示顺序。 +func Platforms() []PlatformSpec { + out := make([]PlatformSpec, len(platformList)) + copy(out, platformList) + return out +} + +// LookupPlatform 返回具体平台的登记信息;composite 与未知值返回 false。 +func LookupPlatform(id string) (PlatformSpec, bool) { + i, ok := platformIndex[id] + if !ok { + return PlatformSpec{}, false + } + return platformList[i], true +} + +// IsConcretePlatform 报告 id 是否为已登记的具体平台(不含 composite)。 +func IsConcretePlatform(id string) bool { + _, ok := platformIndex[id] + return ok +} + +// IsGroupPlatform 报告 id 是否可作为分组平台(具体平台或 composite)。 +func IsGroupPlatform(id string) bool { + return id == PlatformComposite || IsConcretePlatform(id) +} + +// ConcretePlatformIDs 返回全部具体平台标识,按展示顺序。 +func ConcretePlatformIDs() []string { + return PlatformIDsWhere(func(PlatformSpec) bool { return true }) +} + +// PlatformIDsWhere 返回满足条件的具体平台标识,按展示顺序。 +func PlatformIDsWhere(match func(PlatformSpec) bool) []string { + out := make([]string, 0, len(platformList)) + for _, spec := range platformList { + if match(spec) { + out = append(out, spec.ID) + } + } + return out +} + +// compositePrecedenceHead 是组合分组查找顺序的固定前缀(历史顺序:Gemini 先于 OpenAI)。 +var compositePrecedenceHead = []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI} + +// CompositePrecedencePlatformIDs 返回按平台依次回退时的顺序:组合分组在请求目标 +// 解析前逐平台查找定价 / 模型映射(先命中者生效)、拼接默认模型列表,以及调度快照 +// 遍历。沿用历史顺序 anthropic、gemini、openai,其余按展示顺序,新登记的平台追加在末尾。 +func CompositePrecedencePlatformIDs() []string { + out := make([]string, 0, len(platformList)) + seen := make(map[string]struct{}, len(platformList)) + for _, id := range compositePrecedenceHead { + if IsConcretePlatform(id) { + out = append(out, id) + seen[id] = struct{}{} + } + } + for _, spec := range platformList { + if _, ok := seen[spec.ID]; !ok { + out = append(out, spec.ID) + } + } + return out +} + +// UsesOpenAIGateway 报告平台是否由 OpenAI 网关处理。 +func UsesOpenAIGateway(id string) bool { + spec, ok := LookupPlatform(id) + return ok && spec.Gateway == PlatformGatewayOpenAI +} + +// IsCNProviderPlatform 报告平台是否为国产单一厂商。 +func IsCNProviderPlatform(id string) bool { + spec, ok := LookupPlatform(id) + return ok && spec.CNProvider +} diff --git a/backend/internal/domain/platforms_test.go b/backend/internal/domain/platforms_test.go new file mode 100644 index 0000000000..327fc1c315 --- /dev/null +++ b/backend/internal/domain/platforms_test.go @@ -0,0 +1,67 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// 重构前各处手写的平台列表,作为平台清单派生结果的等价基准。 +var ( + legacyDisplayOrder = []string{ + PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, + } + legacyCompositePrecedence = []string{ + PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, + } + legacyOpenAIGateway = []string{ + PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + } + legacyCNProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} + legacyLiteLLMByPlatf = map[string]string{ + PlatformAnthropic: "anthropic", PlatformOpenAI: "openai", PlatformGemini: "gemini", + PlatformAntigravity: "anthropic", PlatformGrok: "xai", PlatformKimi: "moonshot", + PlatformZhipu: "zhipu", PlatformDeepseek: "deepseek", PlatformMiniMax: "minimax", + PlatformOpenCodeGo: "opencode-go", PlatformTypeSafe: "typesafe", + } +) + +func TestPlatformListMatchesLegacyLists(t *testing.T) { + require.Equal(t, legacyDisplayOrder, ConcretePlatformIDs()) + require.Equal(t, legacyCompositePrecedence, CompositePrecedencePlatformIDs()) + require.Equal(t, legacyOpenAIGateway, PlatformIDsWhere(func(spec PlatformSpec) bool { + return spec.Gateway == PlatformGatewayOpenAI + })) + require.Equal(t, legacyCNProviders, PlatformIDsWhere(func(spec PlatformSpec) bool { return spec.CNProvider })) + for platform, provider := range legacyLiteLLMByPlatf { + spec, ok := LookupPlatform(platform) + require.True(t, ok, platform) + require.Equal(t, provider, spec.LiteLLMProvider, platform) + } +} + +func TestPlatformListPredicates(t *testing.T) { + for _, platform := range legacyDisplayOrder { + require.True(t, IsConcretePlatform(platform), platform) + require.True(t, IsGroupPlatform(platform), platform) + spec, ok := LookupPlatform(platform) + require.True(t, ok, platform) + require.NotEmpty(t, spec.DisplayName, platform) + require.NotEmpty(t, spec.Gateway, platform) + } + require.False(t, IsConcretePlatform(PlatformComposite)) + require.True(t, IsGroupPlatform(PlatformComposite)) + for _, invalid := range []string{"", "moonshot", "Kimi", "openai ", "glm", "bogus"} { + require.False(t, IsConcretePlatform(invalid), invalid) + require.False(t, IsGroupPlatform(invalid), invalid) + require.False(t, UsesOpenAIGateway(invalid), invalid) + } +} + +func TestPlatformsReturnsCopy(t *testing.T) { + platforms := Platforms() + platforms[0].ID = "mutated" + require.Equal(t, PlatformAnthropic, Platforms()[0].ID) +} diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 065aedb2f4..4d2aa03e15 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "github.com/Wei-Shaw/sub2api/internal/domain" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -631,20 +632,14 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) { }) } -// platformToLiteLLMProvider maps a channel platform name to the corresponding -// LiteLLM provider string used as the key in the pricing catalog. -var platformToLiteLLMProvider = map[string]string{ - service.PlatformAnthropic: "anthropic", - service.PlatformOpenAI: "openai", - service.PlatformGemini: "gemini", - service.PlatformAntigravity: "anthropic", - service.PlatformGrok: "xai", - service.PlatformKimi: "moonshot", - service.PlatformZhipu: "zhipu", - service.PlatformDeepseek: "deepseek", - service.PlatformMiniMax: "minimax", - service.PlatformOpenCodeGo: "opencode-go", - service.PlatformTypeSafe: "typesafe", +// platformLiteLLMProvider maps a channel platform name to the corresponding +// LiteLLM provider string used as the key in the pricing catalog (platform list). +func platformLiteLLMProvider(platform string) (string, bool) { + spec, ok := domain.LookupPlatform(platform) + if !ok || spec.LiteLLMProvider == "" { + return "", false + } + return spec.LiteLLMProvider, true } // SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表 @@ -657,7 +652,7 @@ func (h *ChannelHandler) SyncPricingModels(c *gin.Context) { return } - provider, ok := platformToLiteLLMProvider[platform] + provider, ok := platformLiteLLMProvider(platform) if !ok { response.ErrorFrom(c, infraerrors.BadRequest("UNSUPPORTED_PLATFORM", fmt.Sprintf("unsupported platform: %s", platform)). diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 774a00f882..e35ed66571 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 minimax opencode_go typesafe composite"` + Platform string `json:"platform" binding:"omitempty,group_platform"` 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 minimax opencode_go typesafe composite"` + Platform string `json:"platform" binding:"omitempty,group_platform"` 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 minimax opencode_go typesafe"` + TargetPlatform string `json:"target_platform" binding:"required,concrete_platform"` 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/platform_validation.go b/backend/internal/handler/admin/platform_validation.go new file mode 100644 index 0000000000..7eff99b88d --- /dev/null +++ b/backend/internal/handler/admin/platform_validation.go @@ -0,0 +1,24 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" +) + +// 平台相关的请求绑定校验标签,由平台清单(domain/platforms.go)驱动,替代在 +// binding 标签里手写 oneof 白名单(新增平台时易漏加,导致分组无法创建): +// - group_platform:已登记的具体平台或 composite; +// - concrete_platform:已登记的具体平台。 +func init() { + engine, ok := binding.Validator.Engine().(*validator.Validate) + if !ok { + return + } + _ = engine.RegisterValidation("group_platform", func(fl validator.FieldLevel) bool { + return domain.IsGroupPlatform(fl.Field().String()) + }) + _ = engine.RegisterValidation("concrete_platform", func(fl validator.FieldLevel) bool { + return domain.IsConcretePlatform(fl.Field().String()) + }) +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index beb9400d11..0b58876d8d 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1301,7 +1301,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, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} { + for _, platform := range domain.CompositePrecedencePlatformIDs() { if platform == service.PlatformTypeSafe && !includeSystemOne { continue } @@ -1503,10 +1503,13 @@ func defaultModelIDsForPlatform(platform string) []string { case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) - // TypeSafe is deliberately absent: jev-latest only works through - // /v1/systemone, so the static fallback never advertises it to LLM - // clients. compositeAvailableModels lists it when the group can serve it. - for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} { + for _, concretePlatform := range domain.CompositePrecedencePlatformIDs() { + // TypeSafe is deliberately skipped: jev-latest only works through + // /v1/systemone, so the static fallback never advertises it to LLM + // clients. compositeAvailableModels lists it when the group can serve it. + if concretePlatform == service.PlatformTypeSafe { + continue + } for _, id := range defaultModelIDsForPlatform(concretePlatform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 21f0cace70..65d002e54b 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -17,6 +17,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" @@ -330,9 +331,9 @@ 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.PlatformMiniMax, service.PlatformOpenCodeGo) + domain.PlatformIDsWhere(func(spec domain.PlatformSpec) bool { + return spec.Gateway == domain.PlatformGatewayOpenAI + })...) } // isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index da928125f2..c4ca164dc0 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -49,21 +49,9 @@ const ( PlatformTypeSafe = domain.PlatformTypeSafe ) -// AllPlatforms 返回所有支持的平台列表 +// AllPlatforms 返回所有支持的平台列表(平台清单,按展示顺序)。 func AllPlatforms() []string { - return []string{ - PlatformAnthropic, - PlatformOpenAI, - PlatformGemini, - PlatformAntigravity, - PlatformGrok, - PlatformKimi, - PlatformZhipu, - PlatformDeepseek, - PlatformMiniMax, - PlatformOpenCodeGo, - PlatformTypeSafe, - } + return domain.ConcretePlatformIDs() } // Validate 验证规则配置的有效性 diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 870ae14c5c..a921a42d44 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/handler" pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel" @@ -46,22 +47,15 @@ func RegisterGatewayRoutes( groupModelAllowlist := middleware.GroupModelAllowlist() isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool { - switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformGrok, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, - service.PlatformMiniMax, service.PlatformOpenCodeGo: - // 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。 - return true - default: - return false - } + // openai、grok 与多协议 API Key 供应商经 OpenAI 网关转发(平台清单)。 + return domain.UsesOpenAIGateway(getGroupPlatform(c)) } countTokensHandler := func(c *gin.Context) { - switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo: - h.OpenAIGateway.CountTokens(c) - case service.PlatformGrok: + switch platform := getGroupPlatform(c); { + case platform == service.PlatformGrok: h.OpenAIGateway.GrokCountTokens(c) + case domain.UsesOpenAIGateway(platform): + h.OpenAIGateway.CountTokens(c) default: h.Gateway.CountTokens(c) } diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 97a797580c..04b609e9e5 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -303,7 +303,7 @@ func (a *Account) IsCNProvider() bool { // openai/grok 原生走 OpenAI 网关;国产供应商同为 OpenAI Chat Completions // 兼容上游,也经 OpenAI 网关转发。OpenCode 同样经 OpenAI 网关按模型分流。 func (a *Account) IsOpenAICompatible() bool { - return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider() || a.IsOpenCodeGo()) + return a != nil && domain.UsesOpenAIGateway(a.Platform) } func (a *Account) GeminiOAuthType() string { @@ -1384,7 +1384,7 @@ func (a *Account) IsOpenAIApiKey() bool { // 适用 openai、国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 OpenCode Go; // grok 走 GetGrokBaseURL,此处对 grok 返回 "" 以保持原有行为。 func (a *Account) GetOpenAIBaseURL() string { - if !a.IsOpenAI() && !a.IsCNProvider() && !a.IsOpenCodeGo() { + if !a.IsOpenAI() && !a.IsMultiProtocolAPIKey() { return "" } if a.IsMultiProtocolAPIKey() && a.IsAdaptiveAPIProtocol() { diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go index 7f05e253ac..e807cac15d 100644 --- a/backend/internal/service/account_header_override.go +++ b/backend/internal/service/account_header_override.go @@ -77,10 +77,10 @@ func (a *Account) IsHeaderOverrideEligible() bool { if a == nil { return false } - switch a.Platform { - case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + switch { + case a.Platform == PlatformAnthropic, a.Platform == PlatformOpenAI, IsMultiProtocolAPIKeyProvider(a.Platform): return a.Type == AccountTypeAPIKey - case PlatformGrok: + case a.Platform == PlatformGrok: return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth default: return false diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index bbf668eaff..7980a5c447 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -522,10 +522,12 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error { case PlatformTypeSafe: // TypeSafe credentials are API keys; inference failures drive health and cooldown state. return nil - case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: - // 国产 OpenAI 兼容供应商与 OpenCode:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。 - return nil default: + if IsMultiProtocolAPIKeyProvider(account.Platform) { + // 多协议 API Key 供应商(国产厂商与聚合平台):凭证为 API Key,实际可用性 + // 经余额/额度探测与转发路径验证。 + return nil + } return fmt.Errorf("unsupported platform: %s", account.Platform) } } diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 103c9edc25..41d16bb6c9 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -10,6 +10,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -319,10 +320,13 @@ func defaultAllowImageGenerationForPlatform(platform string) bool { func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) - // TypeSafe stays out of the static composite candidates (jev-latest only works - // through /v1/systemone); groups with TypeSafe accounts still get it from the - // account model mappings collected by GetGroupModelsListCandidates. - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { + for _, platform := range domain.CompositePrecedencePlatformIDs() { + // TypeSafe stays out of the static composite candidates (jev-latest only works + // through /v1/systemone); groups with TypeSafe accounts still get it from the + // account model mappings collected by GetGroupModelsListCandidates. + if platform == PlatformTypeSafe { + continue + } for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index b2c605cd80..85d6409301 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "time" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" @@ -368,7 +369,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, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} + return domain.CompositePrecedencePlatformIDs() } return []string{groupPlatform} } diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index c10e335cbe..b7fcfd3451 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" ) @@ -204,11 +205,5 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou } func isConcreteRequestPlatform(platform string) bool { - switch platform { - case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe: - return true - default: - return false - } + return domain.IsConcretePlatform(platform) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 014e6f6bb9..c6fc312753 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -101,14 +101,10 @@ const ( DefaultOpenCodeZenAnthropicBaseURL = "https://opencode.ai/zen" ) -// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。 +// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax), +// 以平台清单(domain/platforms.go)为准。 func IsCNProvider(platform string) bool { - switch platform { - case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: - return true - default: - return false - } + return domain.IsCNProviderPlatform(platform) } // IsOpenCodeGo 报告 platform 是否为 OpenCode Go 订阅网关。 @@ -123,22 +119,9 @@ func IsMultiProtocolAPIKeyProvider(platform string) bool { return LookupProviderProfile(platform) != nil } -// AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。 -// ent/schema/user_platform_quota.go 的 Validate 函数独立维护(构建期约束), -// 若新增平台需同步修改该 schema。 -var AllowedQuotaPlatforms = []string{ - PlatformAnthropic, - PlatformOpenAI, - PlatformGemini, - PlatformAntigravity, - PlatformGrok, - PlatformKimi, - PlatformZhipu, - PlatformDeepseek, - PlatformMiniMax, - PlatformOpenCodeGo, - PlatformTypeSafe, -} +// AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表:全部已登记的 +// 具体平台(domain/platforms.go),ent/schema/user_platform_quota.go 的校验同源。 +var AllowedQuotaPlatforms = domain.ConcretePlatformIDs() // AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 // openai/anthropic/grok 有原生用量窗口;kimi/zhipu/minimax 的 Coding Plan 同样暴露 diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index fe93880ee0..04fc18bb94 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/gin-gonic/gin" @@ -285,18 +286,16 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount( ) } -// NormalizeOpenAICompatiblePlatform 保留 grok 与国产 OpenAI 兼容供应商(kimi/zhipu/ -// deepseek)的原值,其他值一律归一为 openai。调度器据此对账号与请求做精确平台匹配: +// NormalizeOpenAICompatiblePlatform 保留走 OpenAI 网关的平台(openai、grok 与多协议 +// API Key 供应商,见平台清单)的原值,其他值一律归一为 openai。调度器据此对账号与请求做精确平台匹配: // kimi 分组请求只命中 kimi 账号,语义与 openai/grok 一致。 // (upstream 曾将本函数改为未导出 normalizeOpenAICompatiblePlatform,本分支的 // handler 调度入口仍需导出,保持导出名。) func NormalizeOpenAICompatiblePlatform(platform string) string { - switch platform { - case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + if domain.UsesOpenAIGateway(platform) { return platform - default: - return PlatformOpenAI } + return PlatformOpenAI } // noAvailableOpenAISelectionError builds the standard "no account available" error diff --git a/backend/internal/service/platform_list_test.go b/backend/internal/service/platform_list_test.go new file mode 100644 index 0000000000..09cc70b090 --- /dev/null +++ b/backend/internal/service/platform_list_test.go @@ -0,0 +1,118 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/stretchr/testify/require" +) + +// 以下为重构前各处手写的平台列表 / switch,作为平台清单派生结果的等价基准。 +var ( + legacyAllPlatforms = []string{ + PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, + } + legacySchedulerSnapshotPlatforms = []string{ + PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, + } + legacyCompositeMatchingPlatforms = legacySchedulerSnapshotPlatforms + legacyMultiProtocolProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} + platformProbeValues = append(append([]string{}, legacyAllPlatforms...), PlatformComposite, "", "moonshot", "Kimi", "openai ", "glm", "bogus") + platformProbeAccountTypes = []string{AccountTypeAPIKey, AccountTypeOAuth, AccountTypeSetupToken, AccountTypeUpstream, ""} +) + +func legacyIsCNProvider(platform string) bool { + switch platform { + case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: + return true + } + return false +} + +func legacyIsOpenAICompatible(platform string) bool { + return platform == PlatformOpenAI || platform == PlatformGrok || legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo +} + +func legacyNormalizeOpenAICompatiblePlatform(platform string) string { + switch platform { + case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + return platform + } + return PlatformOpenAI +} + +func legacyIsUpstreamBillingProbeIdentity(platform, accountType string) bool { + if accountType != AccountTypeAPIKey { + return false + } + switch platform { + case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformTypeSafe: + return true + } + return false +} + +func legacyIsHeaderOverrideEligible(platform, accountType string) bool { + switch platform { + case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + return accountType == AccountTypeAPIKey + case PlatformGrok: + return accountType == AccountTypeAPIKey || accountType == AccountTypeOAuth + } + return false +} + +func legacyIsConcreteRequestPlatform(platform string) bool { + for _, p := range legacyAllPlatforms { + if p == platform { + return true + } + } + return false +} + +func TestPlatformListDerivedListsMatchLegacy(t *testing.T) { + require.Equal(t, legacyAllPlatforms, AllowedQuotaPlatforms) + require.Equal(t, legacySchedulerSnapshotPlatforms, schedulerSnapshotPlatforms()) + require.Equal(t, legacyCompositeMatchingPlatforms, matchingPlatforms(PlatformComposite)) + require.Equal(t, []string{PlatformKimi}, matchingPlatforms(PlatformKimi)) + + var multiProtocol []string + for _, platform := range legacyAllPlatforms { + if IsMultiProtocolAPIKeyProvider(platform) { + multiProtocol = append(multiProtocol, platform) + } + } + require.Equal(t, legacyMultiProtocolProviders, multiProtocol) +} + +func TestPlatformListDerivedPredicatesMatchLegacy(t *testing.T) { + for _, platform := range platformProbeValues { + require.Equal(t, legacyIsCNProvider(platform), IsCNProvider(platform), platform) + require.Equal(t, legacyIsConcreteRequestPlatform(platform), isConcreteRequestPlatform(platform), platform) + require.Equal(t, legacyNormalizeOpenAICompatiblePlatform(platform), NormalizeOpenAICompatiblePlatform(platform), platform) + require.Equal(t, legacyIsOpenAICompatible(platform), (&Account{Platform: platform}).IsOpenAICompatible(), platform) + for _, accountType := range platformProbeAccountTypes { + require.Equal(t, legacyIsUpstreamBillingProbeIdentity(platform, accountType), + IsUpstreamBillingProbeIdentity(platform, accountType), "%s/%s", platform, accountType) + require.Equal(t, legacyIsHeaderOverrideEligible(platform, accountType), + (&Account{Platform: platform, Type: accountType}).IsHeaderOverrideEligible(), "%s/%s", platform, accountType) + } + } +} + +// 多协议 API Key 供应商经 OpenAI 网关转发(NormalizeOpenAICompatiblePlatform、 +// countTokens 路由等依赖此约束),ProviderProfile 登记的平台必须在平台清单中声明 openai 网关。 +func TestProviderProfilesUseOpenAIGateway(t *testing.T) { + for platform := range providerProfiles { + spec, ok := domain.LookupPlatform(platform) + require.True(t, ok, platform) + require.Equal(t, domain.PlatformGatewayOpenAI, spec.Gateway, platform) + } +} diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 6f999c189c..2073ad0f53 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) @@ -608,10 +609,10 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p continue } accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs) - switch account.Platform { - case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe: + switch { + case account.Platform != PlatformAntigravity && isConcreteRequestPlatform(account.Platform): addPlatformGroups(account.Platform, accountGroupIDs) - case PlatformAntigravity: + case account.Platform == PlatformAntigravity: // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。 addPlatformGroups(PlatformAntigravity, accountGroupIDs) addPlatformGroups(PlatformAnthropic, accountGroupIDs) @@ -824,8 +825,9 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account return s.rebuildBuckets(ctx, buckets, reason) } -func schedulerSnapshotPlatforms() [11]string { - return [11]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} +// schedulerSnapshotPlatforms 返回需要维护调度快照的全部具体平台(平台清单)。 +func schedulerSnapshotPlatforms() []string { + return domain.CompositePrecedencePlatformIDs() } // 生命周期辅助函数有意排除 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 b34900f4f5..a53fca5597 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -19,6 +19,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/domain" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" @@ -1008,17 +1009,7 @@ func decodeUpstreamBillingProbeSnapshot(extra map[string]any) *UpstreamBillingPr // type=apikey by the admin form, so only pre-existing type=upstream rows // cannot turn the probe on. func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { - if accountType != AccountTypeAPIKey { - return false - } - switch platform { - case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, - PlatformTypeSafe: - return true - default: - return false - } + return accountType == AccountTypeAPIKey && domain.IsConcretePlatform(platform) } func isUpstreamBillingProbeAccount(account *Account) bool {