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') }} +
+