From 8fd6f91b7e49030f46a859e6518733ff86175908 Mon Sep 17 00:00:00 2001 From: jjaw Date: Sun, 19 Jul 2026 18:46:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20Responses=20=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E6=84=8F=E5=9B=BE=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/openai_gateway_handler.go | 16 +++--- .../handler/openai_gateway_handler_test.go | 33 ++++++++++++ ...i_responses_image_intent_benchmark_test.go | 53 +++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 backend/internal/handler/openai_responses_image_intent_benchmark_test.go diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 602b971f44..7b697d6d69 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -124,6 +124,13 @@ func openAICompatibleRequestPlatform(apiKey *service.APIKey) string { return service.PlatformOpenAI } +func openAIResponsesRequiredCapability(imageIntent bool, platform string) service.OpenAIEndpointCapability { + if imageIntent && platform == service.PlatformOpenAI { + return service.OpenAIEndpointCapabilityResponses + } + return service.OpenAIEndpointCapabilityChatCompletions +} + func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool { if apiKey == nil || apiKey.Group == nil { return true @@ -363,12 +370,9 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 生图意图的 /v1/responses 请求必须调度到确实支持 Responses API 的账号,否则 // 会在 forward 阶段被静默降级为无法生图的 Chat Completions 直转(#4417)。 // 仅对 OpenAI 平台生效:Grok 生图走独立的 forwardGrokResponses 路径,不应被过滤。 - // 使用 IsExplicitImageGenerationIntent 排除被动 image_gen namespace 声明, - // 避免 Codex 的被动工具目录使 CC-only 账号被误过滤(#4476)。 - requiredCapability := service.OpenAIEndpointCapabilityChatCompletions - if service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, body) && requestPlatform == service.PlatformOpenAI { - requiredCapability = service.OpenAIEndpointCapabilityResponses - } + // 复用前置权限与并发阶段在未修改 body 上确认的显式生图意图,避免大 tools 请求重复扫描。 + // 该判断已排除 Codex 被动 image_gen namespace,避免 CC-only 账号被误过滤(#4476)。 + requiredCapability := openAIResponsesRequiredCapability(imageIntent, requestPlatform) for { // Streaming Forward intentionally detaches the upstream request so usage can diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 635c22c3f8..eace1c7c92 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -138,6 +138,39 @@ func TestOpenAIForwardSucceededForScheduling(t *testing.T) { })) } +func TestOpenAIResponsesRequiredCapability(t *testing.T) { + tests := []struct { + name string + imageIntent bool + platform string + want service.OpenAIEndpointCapability + }{ + { + name: "OpenAI explicit image intent requires Responses", + imageIntent: true, + platform: service.PlatformOpenAI, + want: service.OpenAIEndpointCapabilityResponses, + }, + { + name: "Grok explicit image intent keeps chat capability", + imageIntent: true, + platform: service.PlatformGrok, + want: service.OpenAIEndpointCapabilityChatCompletions, + }, + { + name: "non-image intent keeps chat capability", + platform: service.PlatformOpenAI, + want: service.OpenAIEndpointCapabilityChatCompletions, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, openAIResponsesRequiredCapability(tt.imageIntent, tt.platform)) + }) + } +} + func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *testing.T) { body := []byte(`{"model":"claude-sonnet-4-5","metadata":{"user_id":"claude-code-session"},"messages":[{"role":"user","content":"hello"}]}`) diff --git a/backend/internal/handler/openai_responses_image_intent_benchmark_test.go b/backend/internal/handler/openai_responses_image_intent_benchmark_test.go new file mode 100644 index 0000000000..cb6360e09e --- /dev/null +++ b/backend/internal/handler/openai_responses_image_intent_benchmark_test.go @@ -0,0 +1,53 @@ +package handler + +import ( + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +var openAIResponsesImageIntentRoutingBenchmarkSink service.OpenAIEndpointCapability + +func BenchmarkOpenAIResponsesImageIntentRouting_LargeToolsBody(b *testing.B) { + body := buildLargeOpenAIResponsesToolsBody(32 << 20) + if service.IsExplicitImageGenerationIntent("/v1/responses", "gpt-5.4", body) { + b.Fatal("large tools body must not have explicit image intent") + } + platform := service.PlatformOpenAI + + b.Run("reuse_once", func(b *testing.B) { + b.SetBytes(int64(len(body))) + b.ReportAllocs() + for range b.N { + imageIntent := service.IsExplicitImageGenerationIntent("/v1/responses", "gpt-5.4", body) + openAIResponsesImageIntentRoutingBenchmarkSink = openAIResponsesRequiredCapability(imageIntent, platform) + } + }) + + b.Run("rescan_twice", func(b *testing.B) { + b.SetBytes(int64(len(body))) + b.ReportAllocs() + for range b.N { + imageIntent := service.IsExplicitImageGenerationIntent("/v1/responses", "gpt-5.4", body) + // 对照优化前路径:路由阶段会再次扫描同一份未修改的 body。 + requiredCapability := service.OpenAIEndpointCapabilityChatCompletions + if service.IsExplicitImageGenerationIntent("/v1/responses", "gpt-5.4", body) && platform == service.PlatformOpenAI { + requiredCapability = service.OpenAIEndpointCapabilityResponses + } + if imageIntent && requiredCapability != service.OpenAIEndpointCapabilityResponses { + b.Fatal("explicit image intent must require Responses") + } + openAIResponsesImageIntentRoutingBenchmarkSink = requiredCapability + } + }) +} + +func buildLargeOpenAIResponsesToolsBody(targetBytes int) []byte { + var builder strings.Builder + builder.Grow(targetBytes + 256) + _, _ = builder.WriteString(`{"model":"gpt-5.4","tools":[{"type":"function","name":"search","description":"`) + _, _ = builder.WriteString(strings.Repeat("x", targetBytes)) + _, _ = builder.WriteString(`"}],"tool_choice":"auto","input":"write code"}`) + return []byte(builder.String()) +}