refactor(platform): 后端平台列表改由平台清单派生

新增 domain/platforms.go 作为具体平台的单一数据来源:每个平台一条登记,
写明展示名、所走网关(anthropic / openai / gemini / antigravity)、是否国产
单一厂商、定价目录 provider 名,顺序即展示顺序。

以下列表与判断改为查平台清单:
- "全部具体平台":用户平台额度白名单、错误透传规则平台列表、组合路由目标
  校验、上游计费探测资格、调度快照遍历、组合分组的定价 / 模型映射回退、
  组合分组默认模型列表;
- "OpenAI 网关族":IsOpenAICompatible、NormalizeOpenAICompatiblePlatform、
  Responses 兼容路由判断、count_tokens 分发、组合分组的 OpenAI 文本目标;
- 国产厂商判断、渠道定价同步的 provider 映射;
- 多协议 API Key 供应商相关的请求头覆盖资格、凭证测试、OpenAI 基址回退改用
  IsMultiProtocolAPIKeyProvider;
- 分组平台与组合路由目标的请求校验改为自定义校验标签 group_platform /
  concrete_platform,拒绝规则不变(大小写、首尾空格、别名一律拒绝)。

组合分组的逐平台回退是先命中者生效,CompositePrecedencePlatformIDs 保持
原有顺序 anthropic、gemini、openai,其余按登记顺序追加。依赖具体实现的
能力(OAuth、渠道监控、调度阈值、余额 / 用量窗口、Codex 模型目录等)仍在
各自代码中显式列出。

等价性测试把改动前的每个列表与 switch 原样保留为基准,逐一比较派生结果
(含顺序),谓词覆盖全部平台、composite 与非法值 × 全部账号类型。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SavitarC
2026-10-09 10:10:33 +08:00
co-authored by Claude Opus 5.5
parent 105c955c88
commit 3b89cc08dd
21 changed files with 421 additions and 119 deletions
+1 -1
View File
@@ -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
+135
View File
@@ -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
}
+67
View File
@@ -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)
}
@@ -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)).
@@ -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"`
@@ -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())
})
}
+8 -5
View File
@@ -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
@@ -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
@@ -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 验证规则配置的有效性
+7 -13
View File
@@ -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)
}
+2 -2
View File
@@ -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() {
@@ -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
+5 -3
View File
@@ -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)
}
}
+8 -4
View File
@@ -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
+2 -1
View File
@@ -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}
}
@@ -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)
}
+6 -23
View File
@@ -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 同样暴露
@@ -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
@@ -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)
}
}
@@ -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。
@@ -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 {