fix(gateway): 被动 image_gen namespace 不再强制要求 Responses capability

Fixes #4476
This commit is contained in:
li
2026-07-17 15:07:41 +08:00
parent bc50c9d013
commit e375623abf
3 changed files with 103 additions and 2 deletions
@@ -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
}
@@ -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")
}