Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions backend/internal/service/openai_codex_transform.go
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,49 @@ func normalizeOpenAIResponsesImageGenerationTools(reqBody map[string]any) bool {
return modified
}

// codexImageGenerationBridgeShouldFire reports whether the request carries an
// explicit image-generation signal. Without such a signal we leave the body
// alone so that plain Codex coding requests don't get an unwanted
// image_generation tool injected (issue #2280).
//
// Recognized signals:
// - tools[] already declares image_generation (user-opted in)
// - tool_choice selects image_generation (issue #2254)
// - input contains an input_image part (image edit / multi-modal turn)
// - input contains a previous image_generation_call item (continuation turn)
func codexImageGenerationBridgeShouldFire(reqBody map[string]any) bool {
if len(reqBody) == 0 {
return false
}
if hasOpenAIImageGenerationTool(reqBody) {
return true
}
if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) {
return true
}
if hasOpenAIInputImage(reqBody) {
return true
}
return hasOpenAIImageGenerationCallItem(reqBody["input"])
}

func hasOpenAIImageGenerationCallItem(value any) bool {
items, ok := value.([]any)
if !ok {
return false
}
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
if strings.TrimSpace(firstNonEmptyString(item["type"])) == "image_generation_call" {
return true
}
}
return false
}

func ensureOpenAIResponsesImageGenerationTool(reqBody map[string]any) bool {
if len(reqBody) == 0 {
return false
Expand Down
9 changes: 7 additions & 2 deletions backend/internal/service/openai_gateway_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2075,6 +2075,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group)
}
codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
// 仅当请求里已经携带显式的图片生成信号时才让桥接生效(见
// codexImageGenerationBridgeShouldFire 注释)。否则纯文本 Codex 请求会被
// 注入 image_generation 工具与桥接指令,模型可能在用户未请求时自发调用
// 图片生成(issue #2280)。
codexImageGenerationBridgeShouldApply := codexImageGenerationBridgeEnabled && codexImageGenerationBridgeShouldFire(reqBody)
if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) && !imageGenerationAllowed {
setOpsUpstreamError(c, http.StatusForbidden, ImageGenerationPermissionMessage(), "")
c.JSON(http.StatusForbidden, gin.H{
Expand Down Expand Up @@ -2144,7 +2149,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
markPatchSet("instructions", "You are a helpful coding assistant.")
}

if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(reqBody) {
if codexImageGenerationBridgeShouldApply && ensureOpenAIResponsesImageGenerationTool(reqBody) {
bodyModified = true
disablePatch()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client")
Expand All @@ -2155,7 +2160,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
disablePatch()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload")
}
if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(reqBody) {
if codexImageGenerationBridgeShouldApply && applyCodexImageGenerationBridgeInstructions(reqBody) {
bodyModified = true
disablePatch()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions")
Expand Down
212 changes: 207 additions & 5 deletions backend/internal/service/openai_image_generation_controls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,24 @@ func TestOpenAIGatewayServiceForward_DisabledGroupAllowsTextOnlyResponses(t *tes
func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(t *testing.T) {
gin.SetMode(gin.TestMode)

// 桥接生效需要:分组允许图片生成、桥接配置打开、请求体里携带显式的图片
// 生成信号(这里通过 tool_choice=image_generation 模拟,覆盖 issue #2254)。
imageSignalBody := []byte(`{"model":"gpt-5.4","input":"draw","stream":false,"tool_choice":{"type":"image_generation"}}`)
plainTextBody := []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`)

tests := []struct {
name string
allowImages bool
bridgeEnabled bool
body []byte
wantInjected bool
}{
{name: "disabled group skips injection", allowImages: false, bridgeEnabled: true, wantInjected: false},
{name: "enabled group skips injection by default", allowImages: true, bridgeEnabled: false, wantInjected: false},
{name: "enabled group injects image tool when bridge enabled", allowImages: true, bridgeEnabled: true, wantInjected: true},
// 分组关闭图片生成 + 纯文本请求:桥接条件不满足,不注入。
{name: "disabled group skips injection for text request", allowImages: false, bridgeEnabled: true, body: plainTextBody, wantInjected: false},
// 分组允许 + 桥接关闭:即使带显式信号也不注入。
{name: "bridge disabled skips injection even with signal", allowImages: true, bridgeEnabled: false, body: imageSignalBody, wantInjected: false},
// 分组允许 + 桥接打开 + 携带显式信号:注入工具与桥接指令。
{name: "bridge injects image tool when signal is present", allowImages: true, bridgeEnabled: true, body: imageSignalBody, wantInjected: true},
}

for _, tt := range tests {
Expand All @@ -107,7 +116,7 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(
c, _ := newOpenAIImageGenerationControlTestContext(tt.allowImages, "codex_cli_rs/0.98.0")
account := newOpenAIImageGenerationControlTestAccount()

result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`))
result, err := svc.Forward(context.Background(), c, account, tt.body)

require.NoError(t, err)
require.NotNil(t, result)
Expand All @@ -120,6 +129,198 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(
}
}

// TestOpenAIGatewayServiceForward_BridgeSkipsTextOnlyCodexRequest 覆盖 issue #2280:
// VS Code/CLI Codex 客户端发送纯文本编码请求时,即使桥接全局开关已打开,
// 也不应注入 image_generation 工具或桥接指令,避免模型在用户未请求时自发
// 调用图片生成工具。
func TestOpenAIGatewayServiceForward_BridgeSkipsTextOnlyCodexRequest(t *testing.T) {
gin.SetMode(gin.TestMode)

textOnlyBodies := []struct {
name string
body []byte
}{
{
name: "input string",
body: []byte(`{"model":"gpt-5.4","input":"refactor this Go function","stream":false}`),
},
{
name: "input message array without image",
body: []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"explain this diff"}]}],"stream":false}`),
},
{
name: "with non-image tools",
body: []byte(`{"model":"gpt-5.4","input":"run tests","stream":false,"tools":[{"type":"web_search"}]}`),
},
}

for _, tt := range textOnlyBodies {
t.Run(tt.name, func(t *testing.T) {
upstream := &httpUpstreamRecorder{
resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_text_only","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}`)),
},
}
svc := newOpenAIImageGenerationControlTestService(upstream)
svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = true
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.125.0")
account := newOpenAIImageGenerationControlTestAccount()

result, err := svc.Forward(context.Background(), c, account, tt.body)

require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
require.False(t,
gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists(),
"text-only Codex request must not get image_generation tool injected even when bridge is enabled",
)
instructions := gjson.GetBytes(upstream.lastBody, "instructions").String()
require.NotContains(t, instructions, codexImageGenerationBridgeMarker,
"text-only Codex request must not get the image_generation bridge instructions",
)
})
}
}

// TestOpenAIGatewayServiceForward_BridgeFiresOnImageSignals 覆盖桥接信号的几种
// 触发路径:tool_choice、显式 tools[] 中的 image_generation、input_image 输入、
// 历史 image_generation_call 续链项。
func TestOpenAIGatewayServiceForward_BridgeFiresOnImageSignals(t *testing.T) {
gin.SetMode(gin.TestMode)

tests := []struct {
name string
body []byte
}{
{
name: "tool_choice selects image_generation (#2254)",
body: []byte(`{"model":"gpt-5.4","input":"draw a sunset","stream":false,"tool_choice":{"type":"image_generation"}}`),
},
{
name: "input contains input_image",
body: []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"edit this"},{"type":"input_image","image_url":"https://example.com/a.png"}]}],"stream":false}`),
},
{
name: "input continuation has image_generation_call",
body: []byte(`{"model":"gpt-5.4","input":[{"type":"image_generation_call","id":"ig_prev","result":"prev"}],"stream":false}`),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upstream := &httpUpstreamRecorder{
resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_signal","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}`)),
},
}
svc := newOpenAIImageGenerationControlTestService(upstream)
svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = true
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.125.0")
account := newOpenAIImageGenerationControlTestAccount()

result, err := svc.Forward(context.Background(), c, account, tt.body)

require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
require.True(t,
gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists(),
"image-generation signal must trigger bridge tool injection",
)
instructions := gjson.GetBytes(upstream.lastBody, "instructions").String()
require.Contains(t, instructions, codexImageGenerationBridgeMarker)
})
}
}

// TestCodexImageGenerationBridgeShouldFire 单元测试新加的信号识别函数。
func TestCodexImageGenerationBridgeShouldFire(t *testing.T) {
tests := []struct {
name string
body map[string]any
want bool
}{
{
name: "nil body",
body: nil,
want: false,
},
{
name: "plain text request",
body: map[string]any{"model": "gpt-5.4", "input": "write code"},
want: false,
},
{
name: "tools contains image_generation",
body: map[string]any{
"model": "gpt-5.4",
"tools": []any{map[string]any{"type": "image_generation"}},
},
want: true,
},
{
name: "tool_choice selects image_generation",
body: map[string]any{
"model": "gpt-5.4",
"tool_choice": map[string]any{"type": "image_generation"},
},
want: true,
},
{
name: "tool_choice string image_generation",
body: map[string]any{
"model": "gpt-5.4",
"tool_choice": "image_generation",
},
want: true,
},
{
name: "input_image part",
body: map[string]any{
"model": "gpt-5.4",
"input": []any{
map[string]any{
"type": "message", "role": "user",
"content": []any{
map[string]any{"type": "input_image", "image_url": "https://x/a.png"},
},
},
},
},
want: true,
},
{
name: "image_generation_call continuation item",
body: map[string]any{
"model": "gpt-5.4",
"input": []any{
map[string]any{"type": "image_generation_call", "id": "ig_prev"},
},
},
want: true,
},
{
name: "non-image tool only",
body: map[string]any{
"model": "gpt-5.4",
"tools": []any{map[string]any{"type": "web_search"}},
},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, codexImageGenerationBridgeShouldFire(tt.body))
})
}
}

func TestOpenAIGatewayServiceForward_ExplicitImageToolWorksWithBridgeDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)

Expand Down Expand Up @@ -169,7 +370,8 @@ func TestOpenAIGatewayServiceForward_ChannelBridgeOverrideEnablesCodexInjection(
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0")
account := newOpenAIImageGenerationControlTestAccount()

result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`))
// channel override 打开桥接 + 请求体携带 tool_choice=image_generation 信号。
result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"draw","stream":false,"tool_choice":{"type":"image_generation"}}`))

require.NoError(t, err)
require.NotNil(t, result)
Expand Down
Loading