feat: add Codex image tool strip policy

This commit is contained in:
Anderson
2026-07-03 22:36:39 +08:00
parent 28c2d20d9f
commit f385cdceb7
10 changed files with 296 additions and 15 deletions
@@ -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
}
@@ -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
@@ -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()}})
@@ -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)
@@ -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 {
@@ -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()