diff --git a/backend/internal/service/codex_image_generation_bridge.go b/backend/internal/service/codex_image_generation_bridge.go index c7a894a792..68989d67dc 100644 --- a/backend/internal/service/codex_image_generation_bridge.go +++ b/backend/internal/service/codex_image_generation_bridge.go @@ -4,6 +4,13 @@ import "strings" const featureKeyCodexImageGenerationBridge = "codex_image_generation_bridge" +const ( + featureKeyCodexImageGenerationExplicitToolPolicy = "codex_image_generation_explicit_tool_policy" + + codexImageGenerationExplicitToolPolicyAllow = "allow" + codexImageGenerationExplicitToolPolicyStrip = "strip" +) + func boolOverridePtr(v bool) *bool { return &v } @@ -20,6 +27,27 @@ func boolOverrideFromMap(values map[string]any, keys ...string) *bool { return nil } +func stringOverrideFromMap(values map[string]any, keys ...string) (string, bool) { + if values == nil { + return "", false + } + for _, key := range keys { + if v, ok := values[key].(string); ok { + return v, true + } + } + return "", false +} + +func normalizeCodexImageGenerationExplicitToolPolicy(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case codexImageGenerationExplicitToolPolicyStrip, "remove", "drop": + return codexImageGenerationExplicitToolPolicyStrip + default: + return codexImageGenerationExplicitToolPolicyAllow + } +} + func platformBoolOverride(values map[string]any, key string, platform string) *bool { if values == nil { return nil @@ -62,3 +90,20 @@ func (a *Account) CodexImageGenerationBridgeOverride() *bool { openaiConfig, _ := a.Extra[PlatformOpenAI].(map[string]any) return boolOverrideFromMap(openaiConfig, featureKeyCodexImageGenerationBridge, "codex_image_generation_bridge_enabled") } + +// CodexImageGenerationExplicitToolPolicy returns the account-level policy for +// client-provided Codex /responses image_generation tools. Unknown or unset +// values default to allow to preserve existing behavior. +func (a *Account) CodexImageGenerationExplicitToolPolicy() string { + if a == nil || a.Platform != PlatformOpenAI || a.Extra == nil { + return codexImageGenerationExplicitToolPolicyAllow + } + if policy, ok := stringOverrideFromMap(a.Extra, featureKeyCodexImageGenerationExplicitToolPolicy); ok { + return normalizeCodexImageGenerationExplicitToolPolicy(policy) + } + openaiConfig, _ := a.Extra[PlatformOpenAI].(map[string]any) + if policy, ok := stringOverrideFromMap(openaiConfig, featureKeyCodexImageGenerationExplicitToolPolicy); ok { + return normalizeCodexImageGenerationExplicitToolPolicy(policy) + } + return codexImageGenerationExplicitToolPolicyAllow +} diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 0ece5e44ff..c33ac97f0f 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -607,18 +607,21 @@ func hasOpenAIImageGenerationTool(reqBody map[string]any) bool { return false } -// stripCodexSparkImageGenerationTools removes image_generation tool entries from -// reqBody["tools"]. gpt-5.3-codex-spark rejects that tool upstream with HTTP 400 -// (invalid_request_error, param=tools), and Codex CLI advertises it by default, so -// it must be dropped for spark. When the tools list becomes empty the key is removed. -// Returns true when the body was modified. -func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool { +func stripOpenAIImageGenerationTools(reqBody map[string]any) bool { rawTools, ok := reqBody["tools"] if !ok || rawTools == nil { + if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { + delete(reqBody, "tool_choice") + return true + } return false } tools, ok := rawTools.([]any) if !ok { + if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { + delete(reqBody, "tool_choice") + return true + } return false } filtered := make([]any, 0, len(tools)) @@ -631,17 +634,31 @@ func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool { } filtered = append(filtered, rawTool) } - if !removed { + if !removed && !openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { return false } - if len(filtered) == 0 { - delete(reqBody, "tools") - } else { - reqBody["tools"] = filtered + if removed { + if len(filtered) == 0 { + delete(reqBody, "tools") + } else { + reqBody["tools"] = filtered + } + } + if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { + delete(reqBody, "tool_choice") } return true } +// stripCodexSparkImageGenerationTools removes image_generation tool entries from +// reqBody["tools"]. gpt-5.3-codex-spark rejects that tool upstream with HTTP 400 +// (invalid_request_error, param=tools), and Codex CLI advertises it by default, so +// it must be dropped for spark. When the tools list becomes empty the key is removed. +// Returns true when the body was modified. +func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool { + return stripOpenAIImageGenerationTools(reqBody) +} + func hasOpenAIInputImage(reqBody map[string]any) bool { if reqBody == nil { return false diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index cd84790b98..6bf04ef20f 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2738,8 +2738,25 @@ 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) - imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) + codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow + if isCodexCLI { + codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() + } + codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) + var imageIntent bool + if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if stripOpenAIImageGenerationTools(decoded) { + markDecodedModified() + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy") + } + imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded) + } else { + imageIntent = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) + } if imageIntent && !imageGenerationAllowed { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}}) diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index 6061a0bf9c..31edd36097 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -152,6 +152,45 @@ func TestOpenAIGatewayServiceForward_ExplicitImageToolWorksWithBridgeDisabled(t require.NotContains(t, instructions, "image_generation") } +func TestOpenAIGatewayServiceForward_AccountPolicyStripsExplicitImageTool(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_stripped_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() + account.Extra = map[string]any{ + featureKeyCodexImageGenerationExplicitToolPolicy: codexImageGenerationExplicitToolPolicyStrip, + } + body := []byte(`{ + "model":"gpt-5.4", + "input":"draw", + "stream":false, + "tools":[ + {"type":"function","name":"shell","parameters":{"type":"object"}}, + {"type":"image_generation","format":"jpeg"} + ], + "tool_choice":{"type":"image_generation"} + }`) + + result, err := svc.Forward(context.Background(), c, account, 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()) + require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="function")`).Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.NotContains(t, instructions, "image_generation") +} + func TestOpenAIGatewayServiceForward_ChannelBridgeOverrideEnablesCodexInjection(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 067eeb6029..9261db5544 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -2448,11 +2448,15 @@ func stripCodexSparkImageGenerationToolFromRawPayload(payload []byte, model stri if !isCodexSparkModel(model) || !openAIRequestBodyHasImageGenerationTool(payload) { return payload, false, nil } + return stripOpenAIImageGenerationToolFromRawPayload(payload) +} + +func stripOpenAIImageGenerationToolFromRawPayload(payload []byte) ([]byte, bool, error) { payloadMap := make(map[string]any) if err := json.Unmarshal(payload, &payloadMap); err != nil { return payload, false, err } - if !stripCodexSparkImageGenerationTools(payloadMap) { + if !stripOpenAIImageGenerationTools(payloadMap) { return payload, false, nil } rebuilt, err := json.Marshal(payloadMap) @@ -2671,7 +2675,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } apiKey := getAPIKeyFromContext(c) imageGenerationAllowed := GroupAllowsImageGeneration(apiKeyGroup(apiKey)) - codexBridgeEnabled := isCodexCLI && imageGenerationAllowed && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) + codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow + if isCodexCLI { + codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() + } + codexBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) if codexBridgeEnabled { payloadMap := make(map[string]any) if err := json.Unmarshal(normalized, &payloadMap); err != nil { @@ -2709,6 +2717,14 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } + if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { + if stripped, changed, stripErr := stripOpenAIImageGenerationToolFromRawPayload(normalized); stripErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr) + } else if changed { + normalized = stripped + logOpenAIWSModeInfo("ingress_ws_codex_image_tool_stripped_by_policy account_id=%d", account.ID) + } + } if stripped, changed, stripErr := stripCodexSparkImageGenerationToolFromRawPayload(normalized, upstreamModel); stripErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr) } else if changed { diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 0d19a189b0..ca7c36aaa7 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -169,6 +169,26 @@ func TestStripCodexSparkImageGenerationToolFromRawPayload(t *testing.T) { }) } +func TestStripOpenAIImageGenerationToolFromRawPayload(t *testing.T) { + payload := []byte(`{ + "type":"response.create", + "model":"gpt-5.4", + "tools":[ + {"type":"function","name":"shell"}, + {"type":"image_generation","output_format":"png"} + ], + "tool_choice":{"type":"image_generation"} + }`) + + updated, changed, err := stripOpenAIImageGenerationToolFromRawPayload(payload) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(updated, `tools.#(type=="image_generation")`).Exists()) + require.True(t, gjson.GetBytes(updated, `tools.#(type=="function")`).Exists()) + require.False(t, gjson.GetBytes(updated, "tool_choice").Exists()) +} + func TestAlignStoreDisabledPreviousResponseID(t *testing.T) { t.Parallel() diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index b5f97753d8..57723d4903 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1430,6 +1430,58 @@ + +
+
+
+
+ +
+
+ +

+ {{ t('admin.accounts.openai.codexImageToolPolicyDesc') }} +

+
+
+
+
+ +
+
+
+
+
('inherit') +type CodexImageToolPolicyMode = 'allow' | 'strip' +const codexImageToolPolicyMode = ref('allow') type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer' const anthropicPassthroughEnabled = ref(false) const anthropicAPIKeyAuthScheme = ref('x_api_key') @@ -2703,6 +2757,22 @@ const codexImageGenerationBridgeOptions = computed>(() => [ + { + value: 'allow', + label: t('admin.accounts.openai.codexImageToolPolicyAllow'), + description: t('admin.accounts.openai.codexImageToolPolicyAllowDesc') + }, + { + value: 'strip', + label: t('admin.accounts.openai.codexImageToolPolicyStrip'), + description: t('admin.accounts.openai.codexImageToolPolicyStripDesc') + } +]) const codexImageGenerationBridgeBadgeLabel = computed(() => { switch (codexImageGenerationBridgeMode.value) { case 'enabled': @@ -3036,6 +3106,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false codexImageGenerationBridgeMode.value = 'inherit' + codexImageToolPolicyMode.value = 'allow' anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' @@ -3059,6 +3130,9 @@ const syncFormFromAccount = (newAccount: Account | null) => { } else if (codexImageGenerationBridgeValue === false) { codexImageGenerationBridgeMode.value = 'disabled' } + codexImageToolPolicyMode.value = extra?.codex_image_generation_explicit_tool_policy === 'strip' + ? 'strip' + : 'allow' openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { modeKey: 'openai_oauth_responses_websockets_v2_mode', enabledKey: 'openai_oauth_responses_websockets_v2_enabled', @@ -4199,6 +4273,11 @@ const handleSubmit = async () => { } else { newExtra.codex_image_generation_bridge = codexImageGenerationBridgeMode.value === 'enabled' } + if (codexImageToolPolicyMode.value === 'strip') { + newExtra.codex_image_generation_explicit_tool_policy = 'strip' + } else { + delete newExtra.codex_image_generation_explicit_tool_policy + } if (props.account.type === 'oauth' || props.account.type === 'setup-token') { if (codexCLIOnlyEnabled.value) { diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 381d512ee7..c3646a8e51 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -577,6 +577,41 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled') }) + it('submits account-level Codex image_generation tool strip policy', async () => { + const account = buildAccount() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('button[data-testid="codex-image-tool-policy-strip"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_explicit_tool_policy).toBe('strip') + }) + + it('keeps account-level Codex image_generation tool policy unset when allowing', async () => { + const account = buildAccount() + account.extra = { + codex_image_generation_explicit_tool_policy: 'strip' + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('button[data-testid="codex-image-tool-policy-allow"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_explicit_tool_policy') + }) + it('setup-token account can select and submit OAuth WS mode', async () => { const account = buildOpenAISetupTokenAccount() updateAccountMock.mockReset() diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 831b7d7eae..4b6ec9f35f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3569,6 +3569,13 @@ export default { codexImageGenerationBridgeBadgeInherit: 'Channel policy', codexImageGenerationBridgeBadgeEnabled: 'Account on', codexImageGenerationBridgeBadgeDisabled: 'Account off', + codexImageToolPolicy: 'Codex image tool forwarding', + codexImageToolPolicyDesc: + 'Only applies to client-provided image_generation tools in Codex /responses text requests; standalone image-generation endpoints are unaffected.', + codexImageToolPolicyAllow: 'Allow', + codexImageToolPolicyAllowDesc: 'Default behavior. Keep client-provided image_generation tools.', + codexImageToolPolicyStrip: 'Remove', + codexImageToolPolicyStripDesc: 'Delete image_generation tools and matching tool_choice before forwarding.', compactMode: 'Compact mode', compactModeDesc: 'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 64c62f39f8..9c76037d9d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3742,6 +3742,12 @@ export default { codexImageGenerationBridgeBadgeInherit: '渠道策略', codexImageGenerationBridgeBadgeEnabled: '账号开启', codexImageGenerationBridgeBadgeDisabled: '账号关闭', + codexImageToolPolicy: 'Codex 图片工具传递策略', + codexImageToolPolicyDesc: '仅作用于 Codex /responses 文本端点中客户端显式携带的 image_generation tool;不影响独立图片生成接口。', + codexImageToolPolicyAllow: '放行', + codexImageToolPolicyAllowDesc: '默认行为,保留客户端显式发送的 image_generation tool。', + codexImageToolPolicyStrip: '移除', + codexImageToolPolicyStripDesc: '转发前删除 image_generation tool 和指向它的 tool_choice。', compactMode: 'Compact 模式', compactModeDesc: '控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。',