Files
sub2api/backend/internal/service/composite_route_resolver.go
T
shaw d19265ddb2 fix(gateway): 拒绝含 OpenAI 网关 OAuth 的 composite 歧义路由,防止凭据误入 Anthropic 管线
composite 分组中 openai OAuth 账号与 deepseek API Key 账号同时显式映射同一
gpt 模型时,模型归属判为歧义、路由层不解析平台,请求落到默认 Anthropic
GatewayHandler;handler 再按模型名前缀猜回 openai 并选中 OpenAI OAuth 账号,
ChatGPT token 被发往 api.anthropic.com,上游 401 "Invalid bearer token"
经 failover 逐号打成临时不可调度,单请求即可扫空号池。

- 归属记录声明方是否含 OpenAI 网关族 OAuth / setup-token;当模型名兜底也
  落在 OpenAI 网关族时判为配置错误,路由中间件与 WS 入口直接 400 拒绝并
  提示配置显式路由。纯 API Key 歧义及 Anthropic/Antigravity/Gemini OAuth
  歧义保持原行为。
- 模型归属改按配置计算(ListModelAvailabilityCandidates),不再随限流 /
  临时不可调度在「唯一归属」与「歧义」之间翻转。
- Anthropic 请求构建前校验凭据:非 Anthropic 网关族的 OAuth 凭据不出站,
  返回仅换号、不处罚账号的 failover;count_tokens 返回 404 不支持。
  API Key 不受限(上游可能兼容多种协议格式)。
2026-10-15 14:57:03 +08:00

254 lines
8.3 KiB
Go

package service
import (
"context"
"fmt"
"sort"
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
const (
// CompositeRouteReasonAmbiguous marks a model whose explicit account
// mappings span several provider platforms.
CompositeRouteReasonAmbiguous = "model is exposed by multiple provider platforms"
// CompositeRouteReasonOAuthAmbiguous is the ambiguous case where a claimant
// holds an OAuth / setup-token credential that only the OpenAI gateway can
// carry, and the model-name fallback would also pick an OpenAI-gateway
// platform. The unresolved request would land in the Anthropic gateway
// handler and select those credentials, so this group misconfiguration must
// be rejected instead of guessing a platform from the model name.
CompositeRouteReasonOAuthAmbiguous = "model is exposed by multiple provider platforms including OAuth accounts"
)
// OAuthAmbiguous reports whether the request must be rejected because the
// cross-platform ownership involves OpenAI-gateway OAuth credentials.
func (d CompositeRouteDecision) OAuthAmbiguous() bool {
return !d.Matched && d.Reason == CompositeRouteReasonOAuthAmbiguous
}
// AmbiguousMessage is the client-facing rejection for an OAuthAmbiguous decision.
func (d CompositeRouteDecision) AmbiguousMessage() string {
return fmt.Sprintf("Model %q is mapped on multiple provider platforms in this group; configure an explicit composite route", d.PublicModel)
}
type CompositeRouteResolver struct {
repo CompositeModelRouteRepository
modelOwnershipResolver CompositeModelOwnershipResolver
}
func NewCompositeRouteResolver(repo CompositeModelRouteRepository) *CompositeRouteResolver {
return &CompositeRouteResolver{repo: repo}
}
func (r *CompositeRouteResolver) SetModelOwnershipResolver(resolver CompositeModelOwnershipResolver) {
if r != nil {
r.modelOwnershipResolver = resolver
}
}
// ListExactPublicModels returns enabled, concrete route IDs suitable for a model catalog.
func (r *CompositeRouteResolver) ListExactPublicModels(ctx context.Context, groupID int64, endpoint string, includeSystemOne bool) ([]string, error) {
if r == nil || r.repo == nil || groupID <= 0 {
return nil, nil
}
routes, err := r.repo.ListByGroup(ctx, groupID, false)
if err != nil {
return nil, err
}
models := make([]string, 0, len(routes))
for _, route := range routes {
if route.Enabled && route.MatchType == CompositeRouteMatchExact &&
(endpoint == "" || normalizeCompositeRouteEndpoint(route.Endpoint) == CompositeRouteEndpointAny || normalizeCompositeRouteEndpoint(route.Endpoint) == endpoint) {
if model := strings.TrimSpace(route.PublicModel); model != "" {
models = append(models, model)
}
}
}
if !includeSystemOne {
models = filterSystemOneRouteModels(routes, models, endpoint)
}
return models, nil
}
func (r *CompositeRouteResolver) FilterCodexModels(ctx context.Context, groupID int64, models []string) ([]string, error) {
if r == nil || r.repo == nil || groupID <= 0 {
return models, nil
}
routes, err := r.repo.ListByGroup(ctx, groupID, false)
if err != nil {
return nil, err
}
return filterSystemOneRouteModels(routes, models, CompositeRouteEndpointResponses), nil
}
func filterSystemOneRouteModels(routes []CompositeModelRoute, models []string, endpoint string) []string {
filtered := make([]string, 0, len(models))
for _, model := range models {
route, matched := matchCompositeRoute(routes, model, normalizeCompositeRouteEndpoint(endpoint))
if !matched || route.TargetPlatform != PlatformTypeSafe {
filtered = append(filtered, model)
}
}
return filtered
}
func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, model, endpoint string) (CompositeRouteDecision, error) {
model = strings.TrimSpace(model)
endpoint = normalizeCompositeRouteEndpoint(endpoint)
decision := CompositeRouteDecision{
GroupID: groupID,
PublicModel: model,
Endpoint: endpoint,
}
if model == "" {
decision.Reason = "model is required"
return decision, nil
}
if r != nil && r.repo != nil && groupID > 0 {
routes, err := r.repo.ListByGroup(ctx, groupID, false)
if err != nil {
return decision, fmt.Errorf("list composite routes: %w", err)
}
if route, ok := matchCompositeRoute(routes, model, endpoint); ok {
upstreamModel := strings.TrimSpace(route.UpstreamModel)
if upstreamModel == "" {
upstreamModel = model
}
return CompositeRouteDecision{
Matched: true,
Source: CompositeRouteSourceExplicit,
GroupID: groupID,
PublicModel: model,
TargetPlatform: route.TargetPlatform,
UpstreamModel: upstreamModel,
Endpoint: endpoint,
Route: &route,
}, nil
}
}
if r != nil && r.modelOwnershipResolver != nil && groupID > 0 {
ownership, err := r.modelOwnershipResolver(ctx, groupID, model)
if err != nil {
// A recognizable model can still use the existing detector when the
// account catalog is temporarily unavailable. Unknown aliases cannot.
if _, detectable := DetectModelPlatform(model); !detectable {
return decision, fmt.Errorf("resolve account model ownership: %w", err)
}
} else if ownership.Ambiguous {
decision.Reason = CompositeRouteReasonAmbiguous
// 未解析的请求由下游 handler 按模型名前缀兜底选平台;只有兜底落在
// OpenAI 网关族时,OpenAI 网关 OAuth 声明方才会被误送进 Anthropic 管线。
if guessed, ok := DetectModelPlatform(model); ok && ownership.OpenAIGatewayOAuthClaimed && domain.UsesOpenAIGateway(guessed) {
decision.Reason = CompositeRouteReasonOAuthAmbiguous
}
return decision, nil
} else if ownership.Matched {
platform := strings.TrimSpace(ownership.TargetPlatform)
if !isConcreteRequestPlatform(platform) {
decision.Reason = "account model ownership has no concrete target platform"
return decision, nil
}
return CompositeRouteDecision{
Matched: true,
Source: CompositeRouteSourceAccount,
GroupID: groupID,
PublicModel: model,
TargetPlatform: platform,
UpstreamModel: model,
Endpoint: endpoint,
}, nil
}
}
if platform, ok := DetectModelPlatform(model); ok {
return CompositeRouteDecision{
Matched: true,
Source: CompositeRouteSourceDetector,
GroupID: groupID,
PublicModel: model,
TargetPlatform: platform,
UpstreamModel: model,
Endpoint: endpoint,
}, nil
}
decision.Reason = "no explicit route or built-in detector match"
return decision, nil
}
func matchCompositeRoute(routes []CompositeModelRoute, model, endpoint string) (CompositeModelRoute, bool) {
if len(routes) == 0 {
return CompositeModelRoute{}, false
}
type candidate struct {
route CompositeModelRoute
matchStrength int
endpointWeight int
prefixLen int
}
candidates := make([]candidate, 0, len(routes))
for _, route := range routes {
route.Endpoint = normalizeCompositeRouteEndpoint(route.Endpoint)
if route.Endpoint != endpoint && route.Endpoint != CompositeRouteEndpointAny {
continue
}
route.MatchType = normalizeCompositeRouteMatchType(route.MatchType)
publicModel := strings.TrimSpace(route.PublicModel)
if publicModel == "" {
continue
}
matchStrength := 0
prefixLen := len(publicModel)
switch route.MatchType {
case CompositeRouteMatchExact:
if publicModel != model {
continue
}
matchStrength = 2
case CompositeRouteMatchPrefix:
if !strings.HasPrefix(model, publicModel) {
continue
}
matchStrength = 1
default:
continue
}
endpointWeight := 0
if route.Endpoint == endpoint {
endpointWeight = 1
}
candidates = append(candidates, candidate{
route: route,
matchStrength: matchStrength,
endpointWeight: endpointWeight,
prefixLen: prefixLen,
})
}
if len(candidates) == 0 {
return CompositeModelRoute{}, false
}
sort.SliceStable(candidates, func(i, j int) bool {
a, b := candidates[i], candidates[j]
if a.matchStrength != b.matchStrength {
return a.matchStrength > b.matchStrength
}
if a.endpointWeight != b.endpointWeight {
return a.endpointWeight > b.endpointWeight
}
if a.prefixLen != b.prefixLen {
return a.prefixLen > b.prefixLen
}
if a.route.Priority != b.route.Priority {
return a.route.Priority < b.route.Priority
}
return a.route.ID < b.route.ID
})
return candidates[0].route, true
}