mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
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 不受限(上游可能兼容多种协议格式)。
148 lines
5.5 KiB
Go
148 lines
5.5 KiB
Go
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
|
||
}
|