mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
Merge pull request #8106 from Wei-Shaw/fix/composite-oauth-ambiguous-misroute
fix(gateway): 拒绝含 OpenAI 网关 OAuth 的 composite 歧义路由,防止凭据误入 Anthropic 管线
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user