Files
sub2api/backend/internal/service/composite_platform.go
T
SavitarCandClaude Opus 5.5 3b89cc08dd 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>
2026-10-09 10:10:33 +08:00

210 lines
6.5 KiB
Go

package service
import (
"context"
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
)
// WithResolvedTargetPlatform stores the concrete provider chosen for a request
// made through a composite group.
func WithResolvedTargetPlatform(ctx context.Context, platform string) context.Context {
platform = strings.TrimSpace(platform)
if ctx == nil || platform == "" {
return ctx
}
return context.WithValue(ctx, ctxkey.ResolvedTargetPlatform, platform)
}
// ResolvedTargetPlatformFromContext returns the concrete provider chosen for
// the current request, if one was resolved.
func ResolvedTargetPlatformFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
platform, ok := ctx.Value(ctxkey.ResolvedTargetPlatform).(string)
platform = strings.TrimSpace(platform)
if !ok || platform == "" {
return "", false
}
return platform, true
}
func WithCompositeRouteDecision(ctx context.Context, decision CompositeRouteDecision) context.Context {
if ctx == nil || !decision.Matched {
return ctx
}
ctx = WithResolvedTargetPlatform(ctx, decision.TargetPlatform)
if model := strings.TrimSpace(decision.UpstreamModel); model != "" {
ctx = context.WithValue(ctx, ctxkey.ResolvedUpstreamModel, model)
}
if model := strings.TrimSpace(decision.PublicModel); model != "" {
ctx = context.WithValue(ctx, ctxkey.RequestedPublicModel, model)
}
if source := strings.TrimSpace(decision.Source); source != "" {
ctx = context.WithValue(ctx, ctxkey.CompositeRouteSource, source)
}
return ctx
}
func ResolvedUpstreamModelFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
model, ok := ctx.Value(ctxkey.ResolvedUpstreamModel).(string)
model = strings.TrimSpace(model)
if !ok || model == "" {
return "", false
}
return model, true
}
func RequestedPublicModelFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
model, ok := ctx.Value(ctxkey.RequestedPublicModel).(string)
model = strings.TrimSpace(model)
if !ok || model == "" {
return "", false
}
return model, true
}
func CompositeRouteSourceFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
source, ok := ctx.Value(ctxkey.CompositeRouteSource).(string)
source = strings.TrimSpace(source)
if !ok || source == "" {
return "", false
}
return source, true
}
// DetectModelPlatform maps common public model IDs to the concrete provider
// platform used by sub2api. It intentionally returns false for ambiguous model
// names so composite groups fail closed instead of guessing.
func DetectModelPlatform(model string) (string, bool) {
normalized := strings.ToLower(strings.TrimSpace(model))
if normalized == "" {
return "", false
}
normalized = strings.TrimPrefix(normalized, "models/")
if slash := strings.IndexByte(normalized, '/'); slash > 0 {
provider := strings.TrimSpace(normalized[:slash])
rest := strings.TrimSpace(normalized[slash+1:])
switch provider {
case "anthropic", "claude":
return PlatformAnthropic, true
case "openai", "chatgpt":
return PlatformOpenAI, true
case "google", "google-ai-studio", "gemini":
return PlatformGemini, true
case "xai", "x-ai", "grok":
return PlatformGrok, true
case "kimi", "moonshot":
return PlatformKimi, true
case "zhipu", "glm", "bigmodel":
return PlatformZhipu, true
case "deepseek":
return PlatformDeepseek, true
case "minimax":
return PlatformMiniMax, true
case "typesafe", "jev":
return PlatformTypeSafe, true
}
if rest != "" {
normalized = strings.TrimPrefix(rest, "models/")
}
}
switch {
case strings.HasPrefix(normalized, "anthropic.claude-"),
strings.HasPrefix(normalized, "claude-"):
return PlatformAnthropic, true
case strings.HasPrefix(normalized, "gpt-"),
strings.HasPrefix(normalized, "chatgpt-"),
strings.HasPrefix(normalized, "codex-"),
strings.HasPrefix(normalized, "text-embedding-"),
strings.HasPrefix(normalized, "text-moderation-"),
strings.HasPrefix(normalized, "omni-moderation-"),
strings.HasPrefix(normalized, "dall-e-"),
strings.HasPrefix(normalized, "gpt-image-"),
strings.HasPrefix(normalized, "tts-"),
strings.HasPrefix(normalized, "whisper-"),
hasOpenAISeriesPrefix(normalized):
return PlatformOpenAI, true
case strings.HasPrefix(normalized, "gemini-"),
strings.HasPrefix(normalized, "learnlm-"):
return PlatformGemini, true
case normalized == "grok" || strings.HasPrefix(normalized, "grok-"):
return PlatformGrok, true
case normalized == "k3",
normalized == "k3-256k",
strings.HasPrefix(normalized, "kimi-"),
strings.HasPrefix(normalized, "moonshot-"):
return PlatformKimi, true
case strings.HasPrefix(normalized, "glm-"):
return PlatformZhipu, true
case strings.HasPrefix(normalized, "deepseek-"):
return PlatformDeepseek, true
case strings.HasPrefix(normalized, "minimax-"),
strings.HasPrefix(normalized, "abab5"),
strings.HasPrefix(normalized, "abab6"),
strings.HasPrefix(normalized, "abab7"):
return PlatformMiniMax, true
case normalized == "jev-latest" || strings.HasPrefix(normalized, "jev-"):
return PlatformTypeSafe, true
default:
return "", false
}
}
func hasOpenAISeriesPrefix(model string) bool {
for _, prefix := range []string{"o1", "o3", "o4", "o5"} {
if model == prefix || strings.HasPrefix(model, prefix+"-") {
return true
}
}
return false
}
func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, group *Group, requestedModel, endpoint string) (CompositeRouteDecision, bool, error) {
if group == nil || group.Platform != PlatformComposite {
return CompositeRouteDecision{}, false, nil
}
if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok {
upstreamModel := requestedModel
if resolvedModel, modelOK := ResolvedUpstreamModelFromContext(ctx); modelOK {
upstreamModel = resolvedModel
}
source := CompositeRouteSourceDetector
if resolvedSource, sourceOK := CompositeRouteSourceFromContext(ctx); sourceOK {
source = resolvedSource
}
return CompositeRouteDecision{
Matched: true,
Source: source,
GroupID: group.ID,
PublicModel: requestedModel,
TargetPlatform: platform,
UpstreamModel: upstreamModel,
Endpoint: normalizeCompositeRouteEndpoint(endpoint),
}, true, nil
}
decision, err := s.compositeResolver.Resolve(ctx, group.ID, requestedModel, endpoint)
if err != nil {
return decision, false, err
}
return decision, decision.Matched, nil
}
func isConcreteRequestPlatform(platform string) bool {
return domain.IsConcretePlatform(platform)
}