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)