mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
新增 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>
210 lines
6.5 KiB
Go
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)
|
|
}
|