From d19265ddb218c79e951011e6dec54fe17a215223 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 15 Oct 2026 14:57:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E6=8B=92=E7=BB=9D=E5=90=AB=20O?= =?UTF-8?q?penAI=20=E7=BD=91=E5=85=B3=20OAuth=20=E7=9A=84=20composite=20?= =?UTF-8?q?=E6=AD=A7=E4=B9=89=E8=B7=AF=E7=94=B1=EF=BC=8C=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E8=AF=AF=E5=85=A5=20Anthropic=20=E7=AE=A1?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 不受限(上游可能兼容多种协议格式)。 --- .../handler/openai_gateway_handler.go | 5 ++ .../server/routes/composite_platform_test.go | 47 ++++++++++++ backend/internal/server/routes/gateway.go | 8 ++ .../internal/service/composite_model_route.go | 5 ++ .../service/composite_platform_test.go | 31 +++++++- .../service/composite_route_resolver.go | 33 ++++++++- .../service/composite_route_resolver_test.go | 27 +++++++ .../internal/service/gateway_count_tokens.go | 14 ++-- .../gateway_hotpath_optimization_test.go | 4 + backend/internal/service/gateway_service.go | 15 ++-- .../service/gateway_upstream_request.go | 31 ++++++++ .../service/gateway_upstream_request_test.go | 73 +++++++++++++++++++ .../service/openai_account_scheduler_test.go | 4 + 13 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 backend/internal/service/gateway_upstream_request_test.go diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 65d002e54b..3518463f4f 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -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 diff --git a/backend/internal/server/routes/composite_platform_test.go b/backend/internal/server/routes/composite_platform_test.go index 3b660a951e..9841b243c5 100644 --- a/backend/internal/server/routes/composite_platform_test.go +++ b/backend/internal/server/routes/composite_platform_test.go @@ -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) diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 0af1c4cb23..3f09ebf405 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -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) { diff --git a/backend/internal/service/composite_model_route.go b/backend/internal/service/composite_model_route.go index 1201dad5e4..7ebd6c986a 100644 --- a/backend/internal/service/composite_model_route.go +++ b/backend/internal/service/composite_model_route.go @@ -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) diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 5a1db4da58..e4099b5228 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -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{ diff --git a/backend/internal/service/composite_route_resolver.go b/backend/internal/service/composite_route_resolver.go index 5d73de7de0..025f46cf2b 100644 --- a/backend/internal/service/composite_route_resolver.go +++ b/backend/internal/service/composite_route_resolver.go @@ -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) diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index 92827ab659..76e40e4075 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -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) { diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index ec3c2bad0e..95908a3f44 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -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) diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 7a44456102..d1d0671390 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -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 { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 48c74e7d08..e94934dcfe 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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 diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index cdf2319ebb..a7c899ef34 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -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) diff --git a/backend/internal/service/gateway_upstream_request_test.go b/backend/internal/service/gateway_upstream_request_test.go new file mode 100644 index 0000000000..b6093e7fac --- /dev/null +++ b/backend/internal/service/gateway_upstream_request_test.go @@ -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) +} diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 2b68313070..6a31e46cc5 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -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 {