From 499a8ee4235eea151b9907b5cda408b43dc29c0d Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 19 Aug 2026 15:24:26 +0800 Subject: [PATCH] fix(composite): exempt resolved grok/CN targets from messages dispatch gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanitizeGroupMessagesDispatchFields forces AllowMessagesDispatch=false for every non-openai platform including composite, and the gate checked only the group platform — so composite requests resolved to grok/CN targets were rejected 403 on /v1/messages and /v1/messages/count_tokens before reaching the composite target whitelist, leaving the CN rollout unusable for Claude-protocol clients. - allowOpenAICompatibleMessagesDispatch: composite groups resolved to a grok/CN target get the same exemption as the standalone platforms; openai-resolved and unresolved targets keep requiring the switch - resolveOpenAIMessagesDispatchMappedModel: skip the group-level dispatch model mapping (openai-specific gpt defaults) for composite grok/CN targets — model rewriting stays with account-level model_mapping --- .../openai_gateway_cn_dispatch_test.go | 57 +++++++++++++++++-- .../handler/openai_gateway_count_tokens.go | 4 +- .../handler/openai_gateway_handler.go | 25 ++++++-- .../handler/openai_gateway_handler_test.go | 24 ++++---- 4 files changed, 87 insertions(+), 23 deletions(-) diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go index b7f11c5462..c49201bd12 100644 --- a/backend/internal/handler/openai_gateway_cn_dispatch_test.go +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -3,27 +3,74 @@ package handler // CN 分组 /v1/messages 调度闸门回归(修复:正常途径创建的 CN 分组曾恒 403): // sanitizeGroupMessagesDispatchFields 对非 openai 平台强制 AllowMessagesDispatch // =false,故 CN 分组必须与 grok 一样在闸门处豁免,否则原生 Anthropic 直通 -//(Claude Code 主用例)永远不可达。 +//(Claude Code 主用例)永远不可达。composite 分组同理:sanitize 对 composite +// 恒置 false,解析到 grok/CN 目标时必须按目标平台豁免,解析到 openai 目标 +// 仍受开关控制。 import ( + "net/http/httptest" "testing" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) { - require.True(t, allowOpenAICompatibleMessagesDispatch(nil), "无 key 保持放行") + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行") for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} { apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}} - require.True(t, allowOpenAICompatibleMessagesDispatch(apiKey), + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey), "%s 分组必须豁免 allow_messages_dispatch 闸门", platform) } // 非回归:openai 分组仍受开关控制。 openaiOff := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: false}} - require.False(t, allowOpenAICompatibleMessagesDispatch(openaiOff)) + require.False(t, allowOpenAICompatibleMessagesDispatch(nil, openaiOff)) openaiOn := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: true}} - require.True(t, allowOpenAICompatibleMessagesDispatch(openaiOn)) + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, openaiOn)) +} + +func TestAllowOpenAICompatibleMessagesDispatch_CompositeResolvedTargets(t *testing.T) { + gin.SetMode(gin.TestMode) + + newCompositeCtx := func(model string) (*gin.Context, *service.APIKey) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite, AllowMessagesDispatch: false}} + ensureCompositeTargetPlatform(c, apiKey, model) + return c, apiKey + } + + // 解析到 grok/CN 目标:与对应独立分组同语义豁免。 + for _, model := range []string{"grok-4.3", "kimi-k2-thinking", "glm-5.2", "deepseek-v3.2"} { + c, apiKey := newCompositeCtx(model) + require.True(t, allowOpenAICompatibleMessagesDispatch(c, apiKey), "model=%s", model) + } + + // 解析到 openai 目标:仍受开关控制(composite 被 sanitize 恒置 false ⇒ 拒绝)。 + c, apiKey := newCompositeCtx("gpt-5.5") + require.False(t, allowOpenAICompatibleMessagesDispatch(c, apiKey)) + + // 未解析出目标平台:保持拒绝,不放宽。 + cNone, _ := gin.CreateTestContext(httptest.NewRecorder()) + cNone.Request = httptest.NewRequest("POST", "/v1/messages", nil) + require.False(t, allowOpenAICompatibleMessagesDispatch(cNone, + &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite, AllowMessagesDispatch: false}})) +} + +// composite 解析到 grok/CN 目标时,Group 级调度映射(gpt-5.x 默认值为 openai +// 专属)不得注入,模型改写完全交给账号级 model_mapping。 +func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMapping(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, model := range []string{"kimi-k2-thinking", "glm-5.2", "deepseek-v3.2", "grok-4.3"} { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} + ensureCompositeTargetPlatform(c, apiKey, model) + + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model) + } } diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index 3e9ac06e43..ee6e86c4f7 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -204,7 +204,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { zap.Any("group_id", apiKey.GroupID), ) - if !allowOpenAICompatibleMessagesDispatch(apiKey) { + if !allowOpenAICompatibleMessagesDispatch(c, apiKey) { h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error", "This group does not allow /v1/messages dispatch") return @@ -250,7 +250,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { return } routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) - preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) + preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel) reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", parsedReq.Stream)) setOpsRequestContext(c, reqModel, false) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index f386a40b8c..84b6417e5c 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -98,10 +98,18 @@ func openAIForwardSucceededForScheduling(result *service.OpenAIForwardResult) bo return result.SucceededForScheduling() } -func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string { +func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.APIKey, requestedModel string) string { if apiKey == nil || apiKey.Group == nil { return "" } + // composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x + // 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。 + if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { + if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && + (platform == service.PlatformGrok || service.IsCNProvider(platform)) { + return "" + } + } return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel)) } @@ -190,7 +198,7 @@ func openAIResponsesRequiredCapabilityForRequest(imageIntent bool, needsResponse return openAIResponsesRequiredCapability(imageIntent, platform) } -func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool { +func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKey) bool { if apiKey == nil || apiKey.Group == nil { return true } @@ -204,6 +212,15 @@ func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool { if service.IsCNProvider(apiKey.Group.Platform) { return true } + // composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免:sanitize + // 对 composite 同样恒置 false,不豁免则这些目标的 /v1/messages 永远 403; + // 解析到 openai 目标仍受开关控制,维持现状。 + if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { + if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && + (platform == service.PlatformGrok || service.IsCNProvider(platform)) { + return true + } + } return apiKey.Group.AllowMessagesDispatch } @@ -957,7 +974,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { ) // 检查分组是否允许 /v1/messages 调度 - if !allowOpenAICompatibleMessagesDispatch(apiKey) { + if !allowOpenAICompatibleMessagesDispatch(c, apiKey) { h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error", "This group does not allow /v1/messages dispatch") return @@ -1000,7 +1017,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body) routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) - preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) + preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel) reqStream := gjson.GetBytes(body, "stream").Bool() reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 6e8a687d77..898449cc29 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -646,21 +646,21 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { }, }, } - require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) - require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-fable-5")) + require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) + require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-fable-5")) }) t.Run("uses_family_default_when_no_override", func(t *testing.T) { apiKey := &service.APIKey{Group: &service.Group{}} - require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-opus-4-6")) - require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) - require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5-20251001")) + require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-opus-4-6")) + require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) + require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-haiku-4-5-20251001")) }) t.Run("returns_empty_for_non_claude_or_missing_group", func(t *testing.T) { - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, "claude-sonnet-4-5-20250929")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{}, "claude-sonnet-4-5-20250929")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{Group: &service.Group{}}, "gpt-5.4")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, nil, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, &service.APIKey{}, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, &service.APIKey{Group: &service.Group{}}, "gpt-5.4")) }) t.Run("grok_group_maps_claude_cli_model_to_grok_default", func(t *testing.T) { @@ -672,8 +672,8 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { Platform: service.PlatformGrok, }, } - require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "grok")) + require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "grok")) }) t.Run("does_not_fall_back_to_group_default_mapped_model", func(t *testing.T) { @@ -682,8 +682,8 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { DefaultMappedModel: "gpt-5.4", }, } - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "gpt-5.4")) - require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "gpt-5.4")) + require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) }) }