Files
sub2api/backend/internal/service/composite_model_route.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

148 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
const (
CompositeRouteMatchExact = "exact"
CompositeRouteMatchPrefix = "prefix"
CompositeRouteEndpointAny = "any"
CompositeRouteEndpointMessages = "messages"
CompositeRouteEndpointCountTokens = "count_tokens"
CompositeRouteEndpointResponses = "responses"
CompositeRouteEndpointChatCompletions = "chat_completions"
CompositeRouteEndpointEmbeddings = "embeddings"
CompositeRouteEndpointImages = "images"
CompositeRouteEndpointGemini = "gemini"
CompositeRouteSourceExplicit = "route"
CompositeRouteSourceDetector = "detector"
CompositeRouteSourceAccount = "account_model"
)
// CompositeModelOwnership identifies the concrete provider that exposes a
// public model through an account-level exact mapping.
type CompositeModelOwnership struct {
TargetPlatform string
Matched bool
Ambiguous bool
// OpenAIGatewayOAuthClaimed 表示声明方中含 OpenAI 网关族平台的 OAuth /
// setup-token 凭据。歧义请求无法解析平台,会落到默认的 Anthropic 网关
// handler:Anthropic / Gemini / Antigravity 的 OAuth 在那里有原生或兼容
// 转发,API Key 上游可能兼容多种格式,唯独这类凭据只能走 OpenAI 网关。
OpenAIGatewayOAuthClaimed bool
}
type CompositeModelOwnershipResolver func(context.Context, int64, string) (CompositeModelOwnership, error)
var (
ErrCompositeRouteNotFound = infraerrors.NotFound("COMPOSITE_ROUTE_NOT_FOUND", "composite route not found")
ErrCompositeRouteExists = infraerrors.Conflict("COMPOSITE_ROUTE_EXISTS", "composite route already exists")
)
// CompositeModelRoute maps one public model identifier in a composite group to
// the concrete provider/model that should handle the request.
type CompositeModelRoute struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
PublicModel string `json:"public_model"`
MatchType string `json:"match_type"`
TargetPlatform string `json:"target_platform"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CompositeRoutePreviewRequest struct {
Model string `json:"model"`
Endpoint string `json:"endpoint"`
}
type CompositeRouteDecision struct {
Matched bool `json:"matched"`
Source string `json:"source"`
GroupID int64 `json:"group_id"`
PublicModel string `json:"public_model"`
TargetPlatform string `json:"target_platform"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint"`
Route *CompositeModelRoute `json:"route,omitempty"`
Reason string `json:"reason,omitempty"`
}
type CompositeRouteInput struct {
PublicModel string
MatchType string
TargetPlatform string
UpstreamModel string
Endpoint string
Priority int
Enabled bool
Notes string
}
type CompositeModelRouteRepository interface {
ListByGroup(ctx context.Context, groupID int64, includeDisabled bool) ([]CompositeModelRoute, error)
Create(ctx context.Context, route *CompositeModelRoute) error
Update(ctx context.Context, route *CompositeModelRoute) error
Delete(ctx context.Context, id int64) error
DeleteByGroup(ctx context.Context, groupID int64) error
}
func normalizeCompositeRouteEndpoint(endpoint string) string {
endpoint = strings.ToLower(strings.TrimSpace(endpoint))
if endpoint == "" {
return CompositeRouteEndpointAny
}
switch endpoint {
case CompositeRouteEndpointMessages,
CompositeRouteEndpointCountTokens,
CompositeRouteEndpointResponses,
CompositeRouteEndpointChatCompletions,
CompositeRouteEndpointEmbeddings,
CompositeRouteEndpointImages,
CompositeRouteEndpointGemini:
return endpoint
default:
return CompositeRouteEndpointAny
}
}
func normalizeCompositeRouteMatchType(matchType string) string {
matchType = strings.ToLower(strings.TrimSpace(matchType))
switch matchType {
case CompositeRouteMatchPrefix:
return CompositeRouteMatchPrefix
default:
return CompositeRouteMatchExact
}
}
func normalizeCompositeRouteInput(input CompositeRouteInput) CompositeRouteInput {
input.PublicModel = strings.TrimSpace(input.PublicModel)
input.MatchType = normalizeCompositeRouteMatchType(input.MatchType)
input.TargetPlatform = strings.TrimSpace(input.TargetPlatform)
input.UpstreamModel = strings.TrimSpace(input.UpstreamModel)
input.Endpoint = normalizeCompositeRouteEndpoint(input.Endpoint)
// 仅对 exact 路由把空 upstream_model 回填成 public_model:exact 命中时请求模型
// 恒等于 public_model,回填只影响持久化/后台展示,保留原有契约不变。
// prefix 路由留空则不回填——Resolve 会回退到具体请求模型,从而透传原始模型
// (否则 public=deepseek-v4 的前缀路由会把 deepseek-v4-flash / deepseek-v4-pro
// 都塌缩成固定的 deepseek-v4)。显式填写 upstream_model 时任何模式都原样固定转发。
if input.UpstreamModel == "" && input.MatchType == CompositeRouteMatchExact {
input.UpstreamModel = input.PublicModel
}
input.Notes = strings.TrimSpace(input.Notes)
return input
}