Merge pull request #4481 from fengshao1227/fix/passive-image-namespace-capability

fix(gateway): 被动 image_gen namespace 不再强制要求 Responses capability
This commit is contained in:
Wesley Liddick
2026-07-17 16:14:35 +08:00
committed by GitHub
4 changed files with 126 additions and 10 deletions
@@ -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
@@ -353,8 +356,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
}
@@ -1479,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
@@ -1609,8 +1614,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
}
@@ -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)
@@ -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
@@ -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")
}