From e375623abf3b54d95852bef467f30f615250f60b Mon Sep 17 00:00:00 2001 From: li Date: Fri, 17 Jul 2026 15:07:41 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(gateway):=20=E8=A2=AB=E5=8A=A8=20image?= =?UTF-8?q?=5Fgen=20namespace=20=E4=B8=8D=E5=86=8D=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E8=A6=81=E6=B1=82=20Responses=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4476 --- .../handler/openai_gateway_handler.go | 7 ++- .../service/image_generation_intent.go | 35 +++++++++++ .../image_generation_intent_explicit_test.go | 63 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/image_generation_intent_explicit_test.go diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ea23c0cf31..a31011b8ec 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -353,8 +353,10 @@ 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 imageIntent && requestPlatform == service.PlatformOpenAI { + if service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, body) && requestPlatform == service.PlatformOpenAI { requiredCapability = service.OpenAIEndpointCapabilityResponses } @@ -1609,8 +1611,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 与 HTTP Responses 路径保持一致:生图意图请求要求账号支持 Responses API(#4417)。 // WSv2 传输本身已隐含 Responses 支持,此处为防御性对齐。 + // 使用 IsExplicitImageGenerationIntent 排除被动 namespace 声明(#4476)。 requiredCapability := service.OpenAIEndpointCapabilityChatCompletions - if imageIntent && requestPlatform == service.PlatformOpenAI { + if service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, firstMessage) && requestPlatform == service.PlatformOpenAI { requiredCapability = service.OpenAIEndpointCapabilityResponses } diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go index 9351fcd682..30da6ab6ba 100644 --- a/backend/internal/service/image_generation_intent.go +++ b/backend/internal/service/image_generation_intent.go @@ -79,6 +79,41 @@ func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte return imageIntent } +// IsExplicitImageGenerationIntent 仅检测原生 image_generation 工具、图片模型和显式 tool_choice, +// 不检测被动的 image_gen namespace 声明。用于 capability 路由决策——被动 namespace 不应 +// 强制要求原生 Responses 能力,否则 Chat Completions-only 账号会被误过滤(#4476)。 +func IsExplicitImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool { + if IsImageGenerationEndpoint(endpoint) || isOpenAIImageGenerationModel(requestedModel) { + return true + } + if len(body) == 0 || !gjson.ValidBytes(body) { + return false + } + var modelSeen, toolsSeen, toolChoiceSeen bool + imageIntent := false + parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool { + switch key.Str { + case "model": + if !modelSeen { + modelSeen = true + imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String())) + } + case "tools": + if !toolsSeen { + toolsSeen = true + imageIntent = openAIJSONToolsContainNativeImageGeneration(value) + } + case "tool_choice": + if !toolChoiceSeen { + toolChoiceSeen = true + imageIntent = openAIJSONToolChoiceSelectsExplicitImageGeneration(value) + } + } + return !imageIntent && (!modelSeen || !toolsSeen || !toolChoiceSeen) + }) + return imageIntent +} + // IsImageGenerationIntentForPlatform applies platform-specific intent rules. // // Codex advertises the image_gen namespace on ordinary Responses requests so diff --git a/backend/internal/service/image_generation_intent_explicit_test.go b/backend/internal/service/image_generation_intent_explicit_test.go new file mode 100644 index 0000000000..baff2d93f2 --- /dev/null +++ b/backend/internal/service/image_generation_intent_explicit_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsExplicitImageGenerationIntent_IgnoresPassiveNamespace(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.5", + "input": [{"type":"message","role":"user","content":"hello"}], + "tools": [ + {"type":"function","name":"Read"}, + {"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]} + ], + "tool_choice": "auto" + }`) + + assert.False(t, IsExplicitImageGenerationIntent("/v1/responses", "gpt-5.5", body), + "passive image_gen namespace should NOT be explicit image intent") + + assert.True(t, IsImageGenerationIntent("/v1/responses", "gpt-5.5", body), + "passive image_gen namespace SHOULD be general image intent (for permission check)") +} + +func TestIsExplicitImageGenerationIntent_DetectsNativeTool(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.5", + "tools": [{"type":"image_generation","model":"gpt-image-2"}], + "tool_choice": "auto" + }`) + + assert.True(t, IsExplicitImageGenerationIntent("/v1/responses", "gpt-5.5", body), + "native image_generation tool IS explicit intent") +} + +func TestIsExplicitImageGenerationIntent_DetectsImageModel(t *testing.T) { + assert.True(t, IsExplicitImageGenerationIntent("/v1/responses", "gpt-image-2", nil), + "image model IS explicit intent") +} + +func TestIsExplicitImageGenerationIntent_DetectsImageEndpoint(t *testing.T) { + assert.True(t, IsExplicitImageGenerationIntent("/v1/images/generations", "gpt-5.5", nil), + "image endpoint IS explicit intent") +} + +func TestIsExplicitImageGenerationIntent_DetectsExplicitToolChoice(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","tools":[{"type":"function","name":"Read"}],"tool_choice":"image_generation"}`) + assert.True(t, IsExplicitImageGenerationIntent("/v1/responses", "gpt-5.5", body), + "explicit tool_choice selecting image_generation IS explicit intent") +} + +func TestIsExplicitImageGenerationIntent_PlainTextRequest(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.5", + "input": "hello", + "tools": [{"type":"function","name":"Read"}] + }`) + + assert.False(t, IsExplicitImageGenerationIntent("/v1/responses", "gpt-5.5", body), + "plain text request should NOT be explicit image intent") +} From fc3c5a1e0c72e9146d6180b8b445e65e81895bf2 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 17 Jul 2026 15:15:54 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E6=9D=83=E9=99=90=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=92=8C=E5=B9=B6=E5=8F=91=E6=A7=BD=E4=B9=9F=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E6=A3=80=E6=9F=A5=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=A2=AB=E5=8A=A8=20namespace=20=E8=A7=A6=E5=8F=91=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4447 --- backend/internal/handler/openai_gateway_handler.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index a31011b8ec..920a74596d 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -276,7 +276,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { return } - imageIntent := service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey)) + // 使用 IsExplicitImageGenerationIntent 排除被动 image_gen namespace 声明。 + // Codex 在所有请求中被动声明 image_gen namespace,宽泛检测会导致禁了生图的 + // 分组中所有 Codex 请求被 403(#4447),并误占生图并发槽位。 + imageIntent := service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, body) if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) { h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage()) return @@ -1481,7 +1484,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } - imageIntent := service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, firstMessage, openAICompatibleRequestPlatform(apiKey)) + imageIntent := service.IsExplicitImageGenerationIntent("/v1/responses", reqModel, firstMessage) if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage()) return From 5fd030a01b0db6eb987957a541e0cb191cb5e87d Mon Sep 17 00:00:00 2001 From: li Date: Fri, 17 Jul 2026 15:29:14 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=E6=9B=B4=E6=96=B0=20image=20permis?= =?UTF-8?q?sion=20=E6=B5=8B=E8=AF=95=E9=80=82=E9=85=8D=20explicit=20intent?= =?UTF-8?q?=20=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 被动 namespace 不再触发 403,新增 PassiveNamespaceDoesNotTrigger403 测试和 OpenAI native/image model 的正向用例。 --- .../openai_grok_image_intent_gate_test.go | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/openai_grok_image_intent_gate_test.go b/backend/internal/handler/openai_grok_image_intent_gate_test.go index 3c20160c05..2b9d38501d 100644 --- a/backend/internal/handler/openai_grok_image_intent_gate_test.go +++ b/backend/internal/handler/openai_grok_image_intent_gate_test.go @@ -30,17 +30,11 @@ func TestOpenAIGatewayHandlerResponses_GrokResponsesLiteImageToolDeclarationBypa } func TestOpenAIGatewayHandlerResponses_ImagePermissionHardSignalsStillRejected(t *testing.T) { - passiveNamespace := `{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}` tests := []struct { name string platform string body string }{ - { - name: "OpenAI keeps declaration semantics", - platform: service.PlatformOpenAI, - body: passiveNamespace, - }, { name: "Grok native image_generation declaration", platform: service.PlatformGrok, @@ -51,6 +45,16 @@ func TestOpenAIGatewayHandlerResponses_ImagePermissionHardSignalsStillRejected(t platform: service.PlatformGrok, body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen"}],"tool_choice":{"type":"namespace","name":"image_gen"},"input":"draw"}`, }, + { + name: "OpenAI native image_generation tool", + platform: service.PlatformOpenAI, + body: `{"model":"gpt-5.5","tools":[{"type":"image_generation","model":"gpt-image-2"}],"input":"draw a cat"}`, + }, + { + name: "OpenAI image model", + platform: service.PlatformOpenAI, + body: `{"model":"gpt-image-2","input":"draw a cat"}`, + }, } for _, tt := range tests { @@ -63,6 +67,14 @@ func TestOpenAIGatewayHandlerResponses_ImagePermissionHardSignalsStillRejected(t } } +func TestOpenAIGatewayHandlerResponses_PassiveNamespaceDoesNotTrigger403(t *testing.T) { + passiveNamespace := `{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}` + rec := runOpenAIResponsesImagePermissionGateTest(t, service.PlatformOpenAI, passiveNamespace) + + require.NotEqual(t, http.StatusForbidden, rec.Code, + "passive image_gen namespace with tool_choice=auto should not trigger 403 (#4447)") +} + func runOpenAIResponsesImagePermissionGateTest(t *testing.T, platform string, body string) *httptest.ResponseRecorder { t.Helper() gin.SetMode(gin.TestMode)