From 26043a8f29f33443297c027e38c063235cac7434 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Thu, 7 May 2026 00:10:20 +0800 Subject: [PATCH 1/3] fix(openai): gate Codex image bridge injection Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/internal/config/config.go | 4 + .../service/codex_image_generation_bridge.go | 64 +++++++ .../service/openai_gateway_service.go | 20 +- .../openai_image_generation_controls_test.go | 173 +++++++++++++++++- 4 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 backend/internal/service/codex_image_generation_bridge.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e3dc2109..c95fa820 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -611,6 +611,9 @@ type GatewayConfig struct { // ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。 // 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。 ForceCodexCLI bool `mapstructure:"force_codex_cli"` + // CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。 + // 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。 + CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"` // ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。 // 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。 ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"` @@ -1649,6 +1652,7 @@ func setDefaults() { viper.SetDefault("gateway.max_account_switches", 10) viper.SetDefault("gateway.max_account_switches_gemini", 3) viper.SetDefault("gateway.force_codex_cli", false) + viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) // OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚) viper.SetDefault("gateway.openai_ws.enabled", true) diff --git a/backend/internal/service/codex_image_generation_bridge.go b/backend/internal/service/codex_image_generation_bridge.go new file mode 100644 index 00000000..c7a894a7 --- /dev/null +++ b/backend/internal/service/codex_image_generation_bridge.go @@ -0,0 +1,64 @@ +package service + +import "strings" + +const featureKeyCodexImageGenerationBridge = "codex_image_generation_bridge" + +func boolOverridePtr(v bool) *bool { + return &v +} + +func boolOverrideFromMap(values map[string]any, keys ...string) *bool { + if values == nil { + return nil + } + for _, key := range keys { + if v, ok := values[key].(bool); ok { + return boolOverridePtr(v) + } + } + return nil +} + +func platformBoolOverride(values map[string]any, key string, platform string) *bool { + if values == nil { + return nil + } + if v, ok := values[key].(bool); ok { + return boolOverridePtr(v) + } + raw, ok := values[key].(map[string]any) + if !ok { + return nil + } + platform = strings.TrimSpace(platform) + if platform == "" { + return nil + } + if v, ok := raw[platform].(bool); ok { + return boolOverridePtr(v) + } + return nil +} + +// CodexImageGenerationBridgeOverride returns the channel-level override for Codex +// image_generation bridge injection. Nil means follow the global/account policy. +func (c *Channel) CodexImageGenerationBridgeOverride(platform string) *bool { + if c == nil { + return nil + } + return platformBoolOverride(c.FeaturesConfig, featureKeyCodexImageGenerationBridge, platform) +} + +// CodexImageGenerationBridgeOverride returns the account-level override for Codex +// image_generation bridge injection. Nil means follow the channel/global policy. +func (a *Account) CodexImageGenerationBridgeOverride() *bool { + if a == nil || a.Platform != PlatformOpenAI || a.Extra == nil { + return nil + } + if override := boolOverrideFromMap(a.Extra, featureKeyCodexImageGenerationBridge, "codex_image_generation_bridge_enabled"); override != nil { + return override + } + openaiConfig, _ := a.Extra[PlatformOpenAI].(map[string]any) + return boolOverrideFromMap(openaiConfig, featureKeyCodexImageGenerationBridge, "codex_image_generation_bridge_enabled") +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index a5fe707d..e4430536 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -440,6 +440,21 @@ func (s *OpenAIGatewayService) ResolveChannelMappingAndRestrict(ctx context.Cont return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model) } +func (s *OpenAIGatewayService) isCodexImageGenerationBridgeEnabled(ctx context.Context, account *Account, apiKey *APIKey) bool { + if override := account.CodexImageGenerationBridgeOverride(); override != nil { + return *override + } + if s != nil && s.channelService != nil && apiKey != nil && apiKey.GroupID != nil { + ch, err := s.channelService.GetChannelForGroup(ctx, *apiKey.GroupID) + if err != nil { + slog.Warn("failed to resolve codex image generation bridge channel override", "group_id", *apiKey.GroupID, "error", err) + } else if override := ch.CodexImageGenerationBridgeOverride(PlatformOpenAI); override != nil { + return *override + } + } + return s != nil && s.cfg != nil && s.cfg.Gateway.CodexImageGenerationBridgeEnabled +} + func (s *OpenAIGatewayService) checkChannelPricingRestriction(ctx context.Context, groupID *int64, requestedModel string) bool { if groupID == nil || s.channelService == nil || requestedModel == "" { return false @@ -2059,6 +2074,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if apiKey != nil { imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group) } + codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) && !imageGenerationAllowed { setOpsUpstreamError(c, http.StatusForbidden, ImageGenerationPermissionMessage(), "") c.JSON(http.StatusForbidden, gin.H{ @@ -2128,7 +2144,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco markPatchSet("instructions", "You are a helpful coding assistant.") } - if isCodexCLI && imageGenerationAllowed && ensureOpenAIResponsesImageGenerationTool(reqBody) { + if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(reqBody) { bodyModified = true disablePatch() logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client") @@ -2139,7 +2155,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 isCodexCLI && imageGenerationAllowed && applyCodexImageGenerationBridgeInstructions(reqBody) { + if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(reqBody) { bodyModified = true disablePatch() logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions") diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index 76dc8053..9ff8b510 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -83,12 +83,14 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability( gin.SetMode(gin.TestMode) tests := []struct { - name string - allowImages bool - wantInjected bool + name string + allowImages bool + bridgeEnabled bool + wantInjected bool }{ - {name: "disabled group skips injection", allowImages: false, wantInjected: false}, - {name: "enabled group injects image tool", allowImages: true, wantInjected: true}, + {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}, } for _, tt := range tests { @@ -101,6 +103,7 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability( }, } svc := newOpenAIImageGenerationControlTestService(upstream) + svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = tt.bridgeEnabled c, _ := newOpenAIImageGenerationControlTestContext(tt.allowImages, "codex_cli_rs/0.98.0") account := newOpenAIImageGenerationControlTestAccount() @@ -117,6 +120,154 @@ func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability( } } +func TestOpenAIGatewayServiceForward_ExplicitImageToolWorksWithBridgeDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_explicit_image","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0") + account := newOpenAIImageGenerationControlTestAccount() + body := []byte(`{"model":"gpt-5.4","input":"draw","stream":false,"tools":[{"type":"image_generation","format":"jpeg"}]}`) + + result, err := svc.Forward(context.Background(), c, account, 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()) + require.Equal(t, "jpeg", gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation").output_format`).String()) + require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation").format`).Exists()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.NotContains(t, instructions, "image_generation") +} + +func TestOpenAIGatewayServiceForward_ChannelBridgeOverrideEnablesCodexInjection(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_channel_bridge","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":1}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + groupID := int64(4242) + svc.channelService = newOpenAIImageGenerationControlChannelService(groupID, &Channel{ + ID: 9001, + Status: StatusActive, + FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }, + }) + 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}`)) + + 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()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.Contains(t, instructions, "image_generation") +} + +func TestOpenAIGatewayService_CodexImageGenerationBridgeOverridePrecedence(t *testing.T) { + groupID := int64(4242) + + tests := []struct { + name string + global bool + channel *Channel + account *Account + want bool + }{ + { + name: "global default enables bridge", + global: true, + account: &Account{ + Platform: PlatformOpenAI, + }, + want: true, + }, + { + name: "channel true overrides disabled global", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + account: &Account{Platform: PlatformOpenAI}, + want: true, + }, + { + name: "channel false overrides enabled global", + global: true, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: false}, + }}, + account: &Account{Platform: PlatformOpenAI}, + want: false, + }, + { + name: "account false overrides channel and global true", + global: true, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + account: &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{featureKeyCodexImageGenerationBridge: false}, + }, + want: false, + }, + { + name: "nested account true overrides channel false", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: false}, + }}, + account: &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + PlatformOpenAI: map[string]any{"codex_image_generation_bridge_enabled": true}, + }, + }, + want: true, + }, + { + name: "non openai account extra is ignored", + global: false, + account: &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{featureKeyCodexImageGenerationBridge: true}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{}) + svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = tt.global + if tt.channel != nil { + svc.channelService = newOpenAIImageGenerationControlChannelService(groupID, tt.channel) + } + apiKey := &APIKey{GroupID: &groupID} + + got := svc.isCodexImageGenerationBridgeEnabled(context.Background(), tt.account, apiKey) + + require.Equal(t, tt.want, got) + }) + } +} + func TestOpenAIGatewayServiceHandleResponsesImageOutputs_NonStreaming(t *testing.T) { gin.SetMode(gin.TestMode) @@ -180,6 +331,18 @@ func newOpenAIImageGenerationControlTestService(upstream *httpUpstreamRecorder) } } +func newOpenAIImageGenerationControlChannelService(groupID int64, ch *Channel) *ChannelService { + svc := &ChannelService{} + cache := newEmptyChannelCache() + if ch != nil { + cache.channelByGroupID[groupID] = ch + cache.byID[ch.ID] = ch + } + cache.loadedAt = time.Now() + svc.cache.Store(cache) + return svc +} + func newOpenAIImageGenerationControlTestContext(allowImages bool, userAgent string) (*gin.Context, *httptest.ResponseRecorder) { recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) From 9c1f207bffbe94ea3feb2e58a0454581fa622826 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Thu, 7 May 2026 00:10:20 +0800 Subject: [PATCH 2/3] docs: document Codex image bridge switch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- deploy/config.example.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 1670699b..f48280f7 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -202,6 +202,14 @@ gateway: # # 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。 force_codex_cli: false + # Enable Codex image-generation bridge injection for /openai/v1/responses. + # 是否为 Codex /responses 请求自动注入 image_generation 工具与桥接指令。 + # + # Default false keeps text-only Codex requests text-only. Explicit client-provided + # image_generation tools are still forwarded when the group allows image generation. + # 默认 false:保持纯文本 Codex 请求不被改写;客户端显式提供 image_generation tool 时, + # 仍会在分组允许图片生成的情况下正常转发。 + codex_image_generation_bridge_enabled: false # Optional: template file used to build the final top-level Codex `instructions`. # 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。 # From 246e48215dae88455de98c52c6bdaa741632a20a Mon Sep 17 00:00:00 2001 From: Jlypx Date: Thu, 7 May 2026 00:10:20 +0800 Subject: [PATCH 3/3] feat(frontend): add Codex image bridge toggle Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/i18n/locales/en.ts | 2 ++ frontend/src/i18n/locales/zh.ts | 2 ++ frontend/src/views/admin/ChannelsView.vue | 33 +++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5f968ac7..8a952075 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2283,6 +2283,8 @@ export default { webSearchEmulation: 'Web Search Emulation', webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.', webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation', + codexImageGenerationBridge: 'Codex Image Generation Bridge', + codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.', basicSettings: 'Basic Settings', addPlatform: 'Add Platform', noPlatforms: 'Click "Add Platform" to start configuring the channel', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a37a9786..14fe5bbd 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2360,6 +2360,8 @@ export default { webSearchEmulation: 'Web Search 模拟', webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作', webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关', + codexImageGenerationBridge: 'Codex 图片生成桥接', + codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。', basicSettings: '基础设置', addPlatform: '添加平台', noPlatforms: '点击"添加平台"开始配置渠道', diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index e4452b98..89be573e 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -339,6 +339,21 @@ + +
+
+
+ +

+ {{ t('admin.channels.form.codexImageGenerationBridgeHint') }} +

+
+ +
+
+
@@ -643,6 +658,7 @@ interface PlatformSection { model_mapping: Record model_pricing: PricingFormEntry[] web_search_emulation: boolean + codex_image_generation_bridge: boolean account_stats_pricing_rules: FormPricingRule[] } @@ -738,6 +754,7 @@ function addPlatformSection(platform: GroupPlatform) { model_mapping: {}, model_pricing: [], web_search_emulation: false, + codex_image_generation_bridge: false, account_stats_pricing_rules: [], }) } @@ -1047,6 +1064,19 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[ delete featuresConfig.web_search_emulation } + const codexImageGenerationBridge: Record = {} + for (const section of form.platforms) { + if (!section.enabled) continue + if (section.platform === 'openai') { + codexImageGenerationBridge[section.platform] = !!section.codex_image_generation_bridge + } + } + if (Object.keys(codexImageGenerationBridge).length > 0) { + featuresConfig.codex_image_generation_bridge = codexImageGenerationBridge + } else { + delete featuresConfig.codex_image_generation_bridge + } + return { group_ids, model_pricing, model_mapping, features_config: featuresConfig } } @@ -1095,6 +1125,8 @@ function apiToForm(channel: Channel): PlatformSection[] { const fc = channel.features_config const wsEmulation = fc?.web_search_emulation as Record | undefined const webSearchEnabled = wsEmulation?.[platform] === true + const codexImageGenerationBridge = fc?.codex_image_generation_bridge as Record | undefined + const codexImageGenerationBridgeEnabled = codexImageGenerationBridge?.[platform] === true sections.push({ platform, @@ -1104,6 +1136,7 @@ function apiToForm(channel: Channel): PlatformSection[] { model_mapping: { ...mapping }, model_pricing: pricing, web_search_emulation: webSearchEnabled, + codex_image_generation_bridge: codexImageGenerationBridgeEnabled, account_stats_pricing_rules: [], }) }