Merge pull request #8106 from Wei-Shaw/fix/composite-oauth-ambiguous-misroute

fix(gateway): 拒绝含 OpenAI 网关 OAuth 的 composite 歧义路由,防止凭据误入 Anthropic 管线
This commit is contained in:
Wesley Liddick
2026-10-15 15:08:47 +08:00
committed by GitHub
13 changed files with 282 additions and 15 deletions
@@ -2540,6 +2540,11 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "Failed to resolve composite model route")
return
}
if decision.OAuthAmbiguous() {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, decision.AmbiguousMessage())
return
}
if decision.Matched {
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
wsRouteModel = decision.UpstreamModel
@@ -185,6 +185,53 @@ func TestCompositeTargetPlatformMiddlewareRewritesNestedLiveModel(t *testing.T)
require.Equal(t, http.StatusNoContent, w.Code)
}
// Scenario: 回归 —— openai OAuth 与 deepseek API Key 同时映射 gpt-6-astra 时,
// 请求必须在路由层被拒绝,不能落到默认 Anthropic handler 后再按模型名猜平台。
// 仅 API Key 的跨平台歧义保持原行为(放行且不解析平台)。
func TestCompositeTargetPlatformMiddlewareRejectsOnlyOAuthAmbiguousOwnership(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
oauthClaimed bool
wantStatus int
}{
{name: "oauth claimant", oauthClaimed: true, wantStatus: http.StatusBadRequest},
{name: "apikey only", oauthClaimed: false, wantStatus: http.StatusNoContent},
} {
t.Run(tc.name, func(t *testing.T) {
resolver := service.NewCompositeRouteResolver(nil)
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (service.CompositeModelOwnership, error) {
return service.CompositeModelOwnership{Ambiguous: true, OpenAIGatewayOAuthClaimed: tc.oauthClaimed}, nil
})
router := gin.New()
router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(8)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
c.Next()
})))
router.Use(compositeTargetPlatformMiddleware(resolver))
router.POST("/v1/responses", func(c *gin.Context) {
_, resolved := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.False(t, resolved)
c.Status(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-6-astra"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, tc.wantStatus, w.Code)
if tc.oauthClaimed {
require.Contains(t, w.Body.String(), "explicit composite route")
}
})
}
}
func TestCompositeRequestModelFromMultipartLiveSession(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -586,6 +586,14 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver)
c.Abort()
return
}
// 含 OAuth 账号的跨平台歧义是分组配置错误:拒绝而不是放行。否则未解析的
// 请求落到默认 Anthropic handler,handler 再按模型名前缀另猜平台,
// OAuth 凭据被送进不匹配的协议管线(如 ChatGPT token 发往 Anthropic)。
if decision.OAuthAmbiguous() {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": decision.AmbiguousMessage()}})
return
}
if decision.Matched {
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
if upstreamModel := strings.TrimSpace(decision.UpstreamModel); upstreamModel != "" && upstreamModel != model && gjson.ValidBytes(body) {
@@ -32,6 +32,11 @@ 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)
@@ -13,7 +13,7 @@ type compositeOwnershipAccountRepo struct {
accounts []Account
}
func (r *compositeOwnershipAccountRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
func (r *compositeOwnershipAccountRepo) ListModelAvailabilityCandidates(context.Context, *int64, []string, bool) ([]Account, error) {
return r.accounts, nil
}
@@ -103,6 +103,35 @@ func TestResolveCompositeModelOwnershipAllowsSamePlatformAndRejectsCrossPlatform
require.Equal(t, CompositeModelOwnership{Ambiguous: true}, ambiguous)
}
// Scenario: 只有 OpenAI 网关族的 OAuth 声明方会被标记(API Key 上游可能兼容多协议,
// Anthropic / Antigravity 的 OAuth 在默认 Anthropic handler 中可正常转发)
func TestResolveCompositeModelOwnershipFlagsOpenAIGatewayOAuthClaimants(t *testing.T) {
groupID := int64(7)
repo := &compositeOwnershipAccountRepo{
accounts: []Account{
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-6-astra": "gpt-6-astra"}}},
{ID: 2, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-6-astra": "deepseek-v4-pro", "apikey-only": "deepseek-v4-pro"}}},
{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"apikey-only": "gpt-5"}}},
// Antigravity 账号创建时会把默认映射(含 claude-*)写入 model_mapping。
{ID: 4, Platform: PlatformAntigravity, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"claude-opus-4-8": "claude-opus-4-8"}}},
{ID: 5, Platform: PlatformAnthropic, Type: AccountTypeSetupToken, Credentials: map[string]any{"model_mapping": map[string]any{"claude-opus-4-8": "claude-opus-4-8"}}},
},
}
svc := &GatewayService{accountRepo: repo}
withOAuth, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "gpt-6-astra")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{Ambiguous: true, OpenAIGatewayOAuthClaimed: true}, withOAuth)
apiKeyOnly, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "apikey-only")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{Ambiguous: true}, apiKeyOnly)
anthropicFamily, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "claude-opus-4-8")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{Ambiguous: true}, anthropicFamily)
}
func TestNewGatewayServiceWiresCompositeModelOwnershipResolver(t *testing.T) {
groupID := int64(7)
repo := &compositeOwnershipAccountRepo{
@@ -5,8 +5,34 @@ import (
"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
@@ -113,7 +139,12 @@ func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, mod
return decision, fmt.Errorf("resolve account model ownership: %w", err)
}
} else if ownership.Ambiguous {
decision.Reason = "model is exposed by multiple provider platforms"
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)
@@ -221,6 +221,33 @@ func TestCompositeRouteResolverDoesNotGuessAmbiguousAccountOwnership(t *testing.
require.False(t, decision.Matched)
require.Empty(t, decision.TargetPlatform)
require.Equal(t, "model is exposed by multiple provider platforms", decision.Reason)
require.False(t, decision.OAuthAmbiguous())
}
// Scenario: 含 OAuth 账号的跨平台歧义必须被识别为需拒绝的配置错误
func TestCompositeRouteResolverFlagsOAuthAmbiguousOwnership(t *testing.T) {
resolver := NewCompositeRouteResolver(nil)
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
return CompositeModelOwnership{Ambiguous: true, OpenAIGatewayOAuthClaimed: true}, nil
})
// gpt-* 可被内置探测器识别,验证歧义不会退化为按模型名猜平台。
decision, err := resolver.Resolve(context.Background(), 7, "gpt-6-astra", CompositeRouteEndpointResponses)
require.NoError(t, err)
require.False(t, decision.Matched)
require.Empty(t, decision.TargetPlatform)
require.True(t, decision.OAuthAmbiguous())
require.Contains(t, decision.AmbiguousMessage(), `"gpt-6-astra"`)
// 兜底按模型名落到 Anthropic(如 OpenAI OAuth 映射 claude-* 给 Claude Code 用)
// 或无法兜底的自定义别名:OpenAI 网关 OAuth 不会被选中,保持原有行为。
for _, model := range []string{"claude-sonnet-4-6", "team-alias"} {
decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointMessages)
require.NoError(t, err)
require.False(t, decision.OAuthAmbiguous(), "model=%s", model)
require.Equal(t, CompositeRouteReasonAmbiguous, decision.Reason, "model=%s", model)
}
}
func TestCompositeRouteResolverOwnershipLookupErrorFallsBackOnlyForDetectableModels(t *testing.T) {
@@ -22,6 +22,13 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return fmt.Errorf("parse request: empty request")
}
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
// 凭据只能走其他协议的 OAuth 账号同理,且必须在取 token、建请求之前拦下。
if account != nil && (account.Platform == PlatformAntigravity || !anthropicMessagesCredentialAllowed(account)) {
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform")
return nil
}
validationModel := parsed.Model
if account != nil {
@@ -105,13 +112,6 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
}
}
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
if account.Platform == PlatformAntigravity {
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform")
return nil
}
// 应用模型映射:
// - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名
// - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID)
@@ -180,6 +180,10 @@ func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context
return out, nil
}
func (s *modelsListAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
return s.ListSchedulableByGroupID(ctx, *groupID)
}
func (s *modelsListAccountRepoStub) ListSchedulable(ctx context.Context) ([]Account, error) {
s.listAllCalls.Add(1)
if s.err != nil {
+9 -6
View File
@@ -20,6 +20,7 @@ import (
"unsafe"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
@@ -1501,21 +1502,23 @@ func (s *GatewayService) resolveCompositeModelOwnership(ctx context.Context, gro
}
}
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID)
// 归属取决于配置而非运行时健康:限流/临时不可调度不应让同一模型在
// 「唯一归属」与「歧义」之间来回翻转,否则配置错误只会间歇暴露。
accounts, err := s.accountRepo.ListModelAvailabilityCandidates(ctx, &groupID, domain.ConcretePlatformIDs(), false)
if err != nil {
return CompositeModelOwnership{}, err
}
platforms := make(map[string]struct{})
ownership := CompositeModelOwnership{}
for _, account := range accounts {
platform := strings.TrimSpace(account.Platform)
if !isConcreteRequestPlatform(platform) || !explicitModelMappingClaims(account, model) {
continue
if explicitModelMappingClaims(account, model) {
platforms[account.Platform] = struct{}{}
ownership.OpenAIGatewayOAuthClaimed = ownership.OpenAIGatewayOAuthClaimed ||
(account.IsOAuth() && domain.UsesOpenAIGateway(account.Platform))
}
platforms[platform] = struct{}{}
}
ownership := CompositeModelOwnership{}
if len(platforms) == 1 {
for platform := range platforms {
ownership.TargetPlatform = platform
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
@@ -18,7 +19,37 @@ import (
"github.com/gin-gonic/gin"
)
// anthropicMessagesForeignOAuthReason 标记账号凭据与 Anthropic Messages 协议不匹配。
const anthropicMessagesForeignOAuthReason = GatewayFailureReason("anthropic_messages_foreign_oauth")
// anthropicMessagesCredentialAllowed 判断账号凭据能否经 Anthropic Messages 协议
// 出站。OAuth / setup-token 只对签发它的供应商有效(ChatGPT token 只认
// chatgpt.com),送错协议既泄露凭据,上游 401 还会被误记到账号头上;
// API Key 不受限:其上游可能同时兼容多种协议格式。
func anthropicMessagesCredentialAllowed(account *Account) bool {
if !account.IsOAuth() {
return true
}
spec, ok := domain.LookupPlatform(account.Platform)
return ok && spec.Gateway == domain.PlatformGatewayAnthropic
}
// checkAnthropicMessagesCredential 在发出任何上游请求前拦截不匹配的凭据。返回的
// failover 错误只让 handler 换下一个账号:没有上游响应,也就不触发任何账号处罚。
func checkAnthropicMessagesCredential(account *Account) error {
if anthropicMessagesCredentialAllowed(account) {
return nil
}
return &UpstreamFailoverError{
StatusCode: http.StatusBadGateway,
Reason: anthropicMessagesForeignOAuthReason,
}
}
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) {
if err := checkAnthropicMessagesCredential(account); err != nil {
return nil, nil, err
}
body = stripDeferredToolCacheControl(body)
if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream)
@@ -0,0 +1,73 @@
//go:build unit
package service
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// Scenario: OAuth / setup-token 凭据只能走签发方协议,API Key 与其他凭据类型不受限
func TestAnthropicMessagesCredentialAllowed(t *testing.T) {
cases := []struct {
platform string
typ string
allowed bool
}{
{PlatformAnthropic, AccountTypeOAuth, true},
{PlatformAnthropic, AccountTypeSetupToken, true},
{PlatformAnthropic, AccountTypeServiceAccount, true},
{PlatformAnthropic, AccountTypeBedrock, true},
{PlatformTypeSafe, AccountTypeOAuth, true},
{PlatformOpenAI, AccountTypeAPIKey, true},
{PlatformDeepseek, AccountTypeAPIKey, true},
{PlatformOpenAI, AccountTypeOAuth, false},
{PlatformOpenAI, AccountTypeSetupToken, false},
{PlatformGrok, AccountTypeOAuth, false},
{PlatformGemini, AccountTypeOAuth, false},
{PlatformAntigravity, AccountTypeOAuth, false},
}
for _, tc := range cases {
allowed := anthropicMessagesCredentialAllowed(&Account{ID: 1, Platform: tc.platform, Type: tc.typ})
require.Equal(t, tc.allowed, allowed, "%s/%s", tc.platform, tc.typ)
}
}
// Scenario: 回归 —— OpenAI OAuth 账号误入 Anthropic 管线时,在构建请求前即被拒绝
// (ChatGPT token 不会发往 api.anthropic.com),且只让 handler 换号、不处罚账号
func TestGatewayBuildUpstreamRequestRejectsForeignOAuthWithoutPenalty(t *testing.T) {
account := &Account{ID: 25, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
body := []byte(`{"model":"gpt-6-astra","messages":[]}`)
req, _, err := (&GatewayService{}).buildUpstreamRequest(context.Background(), nil, account, body, "chatgpt-token", "oauth", "gpt-6-astra", true, false)
require.Nil(t, req)
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr))
require.True(t, failoverErr.ShouldRetryNextAccount())
require.False(t, failoverErr.RetryableOnSameAccount, "same-account retry would end in temp-unschedulable")
require.False(t, failoverErr.IsCredentialFailure())
require.Equal(t, anthropicMessagesForeignOAuthReason, failoverErr.Reason)
}
// Scenario: count_tokens 对不匹配凭据按「平台不支持」返回 404,不取 token、不出站
func TestForwardCountTokensRejectsForeignOAuthAsUnsupported(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
account := &Account{ID: 25, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
parsed := &ParsedRequest{Model: "gpt-6-astra", Body: NewRequestBodyRef([]byte(`{"model":"gpt-6-astra","messages":[]}`))}
err := (&GatewayService{}).ForwardCountTokens(context.Background(), c, account, parsed)
require.NoError(t, err)
require.Equal(t, http.StatusNotFound, w.Code)
}
@@ -48,6 +48,10 @@ func (r schedulerTestOpenAIAccountRepo) ListSchedulableByGroupID(ctx context.Con
return append([]Account(nil), r.accounts...), nil
}
func (r schedulerTestOpenAIAccountRepo) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
return r.ListSchedulableByGroupID(ctx, *groupID)
}
func (r schedulerTestOpenAIAccountRepo) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) {
var result []Account
for _, acc := range r.accounts {