From f48866958b1ddcda57dab6844b6a3ade7783bc2d Mon Sep 17 00:00:00 2001 From: Yuan Liu <3638888+Yukinoshita-Yukino@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:59:35 +0800 Subject: [PATCH] fix(openai): only inject the Codex image generation bridge for OpenAI accounts The hosted image_generation tool is OpenAI-only. With the global switch or a channel's openai bridge flag enabled, isCodexImageGenerationBridgeEnabled also returned true for Kimi / DeepSeek / MiniMax accounts that forward natively over Responses, so their upstream requests carried the tool, tool_choice and the bridge instructions and were rejected with 400. Return false for nil and non-OpenAI accounts before evaluating the account / channel / global settings. Co-Authored-By: Claude Opus 5.5 --- .../service/openai_gateway_service.go | 7 ++ .../openai_image_generation_controls_test.go | 106 ++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 6cdf340817..91f9b646a4 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -613,7 +613,14 @@ func (s *OpenAIGatewayService) ResolveChannelMappingAndRestrict(ctx context.Cont return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model) } +// isCodexImageGenerationBridgeEnabled 决定是否为 Codex 客户端自动注入 hosted +// image_generation 工具。该工具是 OpenAI Responses 专属能力,Kimi / DeepSeek / +// MiniMax 等经 OpenAI 网关转发的原生 Responses 上游不识别,注入后会被 400 拒绝, +// 因此非 OpenAI 平台账号一律不桥接,后续再按账号 → 渠道 → 全局的优先级取开关。 func (s *OpenAIGatewayService) isCodexImageGenerationBridgeEnabled(ctx context.Context, account *Account, apiKey *APIKey) bool { + if account == nil || account.Platform != PlatformOpenAI { + return false + } if override := account.CodexImageGenerationBridgeOverride(); override != nil { return *override } diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index 300bea3763..782c9f01cd 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -424,6 +424,76 @@ func TestOpenAIGatewayServiceForward_CodexBridgePreservesExistingToolChoice(t *t require.Equal(t, "image_generation", gjson.GetBytes(upstream.lastBody, "tool_choice.type").String()) } +// 国产原生 Responses 账号(Kimi / DeepSeek / MiniMax)在桥接由渠道级开关或全局开关 +// 单独打开时,发往上游的请求体都不能带 hosted image_generation 工具 / tool_choice / 桥接指令。 +func TestOpenAIGatewayServiceForward_CodexBridgeSkipsNativeCNResponsesAccounts(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(4242) + triggers := []struct { + name string + global bool + channel *Channel + }{ + { + name: "channel_openai_true_global_off", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + }, + { + name: "global_on_no_channel", + global: true, + }, + } + for _, platform := range []string{PlatformKimi, PlatformDeepseek, PlatformMiniMax} { + for _, trig := range triggers { + t.Run(platform+"/"+trig.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_cn_codex","model":"cn-model","usage":{"input_tokens":1,"output_tokens":1}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = trig.global + if trig.channel != nil { + svc.channelService = newOpenAIImageGenerationControlChannelService(groupID, trig.channel) + } + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := &Account{ + ID: 6100, + Name: platform + "-native-responses", + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-cn-test", + "api_protocol": APIProtocolResponses, + }, + } + // 前置条件:账号确实走原生 Responses 转发,而不是 Chat Completions 转换链。 + require.True(t, account.UsesNativeCNResponses()) + require.False(t, shouldForwardOpenAIResponsesViaRawChatCompletions(account)) + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"cn-model","input":"hello","stream":false,"tools":[{"type":"function","name":"shell","parameters":{"type":"object"}}]}`)) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Falsef(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists(), "image_generation tool injected into %s upstream body", platform) + require.Falsef(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists(), "tool_choice injected into %s upstream body", platform) + require.NotContains(t, gjson.GetBytes(upstream.lastBody, "instructions").String(), codexImageGenerationBridgeMarker) + require.Equal(t, "shell", gjson.GetBytes(upstream.lastBody, "tools.0.name").String()) + }) + } + } +} + func TestOpenAIGatewayServiceForward_CodexBridgeSkipsCompactRequests(t *testing.T) { gin.SetMode(gin.TestMode) @@ -524,6 +594,42 @@ func TestOpenAIGatewayService_CodexImageGenerationBridgeOverridePrecedence(t *te }, want: false, }, + { + name: "kimi account ignores channel openai true", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + account: &Account{Platform: PlatformKimi, Type: AccountTypeAPIKey}, + want: false, + }, + { + name: "kimi account ignores enabled global", + global: true, + account: &Account{Platform: PlatformKimi, Type: AccountTypeAPIKey}, + want: false, + }, + { + name: "deepseek account ignores enabled global", + global: true, + account: &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}, + want: false, + }, + { + name: "minimax account ignores channel openai true", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + account: &Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey}, + want: false, + }, + { + name: "nil account never bridges", + global: true, + account: nil, + want: false, + }, } for _, tt := range tests {