From 8d4a7f1f2e666667d03487d01655fe8ee576bbae Mon Sep 17 00:00:00 2001 From: Hao Wang Date: Sat, 13 Jun 2026 22:41:15 +0800 Subject: [PATCH] feat(config): add "passthrough" mode for disable-image-generation Adds a fourth value for the disable-image-generation setting: - false: inject image_generation (unchanged) - true: strip everywhere + 404 on /v1/images/* (unchanged) - chat: strip on non-images endpoints, keep /v1/images/* (unchanged) - passthrough: never inject and never strip on non-images endpoints (the client payload is forwarded unchanged); behaves like "chat" on /v1/images/* endpoints. image_generation injection (codex executors) is already gated on the Off mode, and the /v1/images/* 404 gate is already gated on the All mode, so passthrough only required a change to the payload strip logic in payload_helpers.go, now expressed via shouldStripImageGeneration(). Closes #3831 Co-Authored-By: Claude Opus 4.8 --- config.example.yaml | 3 ++- .../config/disable_image_generation_mode.go | 15 +++++++++-- .../disable_image_generation_mode_test.go | 20 ++++++++++++++ internal/config/sdk_config.go | 2 ++ .../runtime/executor/helps/payload_helpers.go | 25 +++++++++++++---- ...d_helpers_disable_image_generation_test.go | 27 +++++++++++++++++++ 6 files changed, 84 insertions(+), 8 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 3c94df54c..08b6aaa23 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -116,9 +116,10 @@ max-retry-interval: 30 # When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states). disable-cooling: false -# disable-image-generation supports: false (default), true, or "chat". +# disable-image-generation supports: false (default), true, "chat", or "passthrough". # - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits). # - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled. +# - "passthrough": never inject or strip image_generation on non-images endpoints (forward the client payload unchanged); behaves like "chat" on /v1/images/* endpoints. disable-image-generation: false # Base model used when proxying gpt-image-2 via the hosted image_generation tool (Responses API). diff --git a/internal/config/disable_image_generation_mode.go b/internal/config/disable_image_generation_mode.go index 1712638b8..792d94a98 100644 --- a/internal/config/disable_image_generation_mode.go +++ b/internal/config/disable_image_generation_mode.go @@ -9,18 +9,21 @@ import ( "gopkg.in/yaml.v3" ) -// DisableImageGenerationMode is a tri-state config value for disable-image-generation. +// DisableImageGenerationMode is a four-state config value for disable-image-generation. // // It supports: // - false: enabled // - true: disabled everywhere (including /v1/images/* endpoints) // - "chat": disabled for all non-images endpoints, but enabled for /v1/images/generations and /v1/images/edits +// - "passthrough": never inject and never strip image_generation on non-images endpoints +// (the client payload is forwarded unchanged); on /v1/images/* endpoints behave like "chat" type DisableImageGenerationMode int const ( DisableImageGenerationOff DisableImageGenerationMode = iota DisableImageGenerationAll DisableImageGenerationChat + DisableImageGenerationPassthrough ) func (m DisableImageGenerationMode) String() string { @@ -31,6 +34,8 @@ func (m DisableImageGenerationMode) String() string { return "true" case DisableImageGenerationChat: return "chat" + case DisableImageGenerationPassthrough: + return "passthrough" default: return "false" } @@ -42,6 +47,8 @@ func (m DisableImageGenerationMode) MarshalYAML() (any, error) { return true, nil case DisableImageGenerationChat: return "chat", nil + case DisableImageGenerationPassthrough: + return "passthrough", nil default: return false, nil } @@ -62,6 +69,8 @@ func (m DisableImageGenerationMode) MarshalJSON() ([]byte, error) { return []byte("true"), nil case DisableImageGenerationChat: return json.Marshal("chat") + case DisableImageGenerationPassthrough: + return json.Marshal("passthrough") default: return []byte("false"), nil } @@ -130,7 +139,9 @@ func parseDisableImageGenerationString(s string) (DisableImageGenerationMode, er return DisableImageGenerationAll, nil case "chat": return DisableImageGenerationChat, nil + case "passthrough": + return DisableImageGenerationPassthrough, nil default: - return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value %q (allowed: true, false, chat)", s) + return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value %q (allowed: true, false, chat, passthrough)", s) } } diff --git a/internal/config/disable_image_generation_mode_test.go b/internal/config/disable_image_generation_mode_test.go index 433a5cbf9..a4338b303 100644 --- a/internal/config/disable_image_generation_mode_test.go +++ b/internal/config/disable_image_generation_mode_test.go @@ -41,6 +41,16 @@ func TestDisableImageGenerationMode_UnmarshalYAML(t *testing.T) { t.Fatalf("chat => %v, want %v", w.V, DisableImageGenerationChat) } } + + { + var w wrapper + if err := yaml.Unmarshal([]byte("disable-image-generation: passthrough\n"), &w); err != nil { + t.Fatalf("unmarshal passthrough: %v", err) + } + if w.V != DisableImageGenerationPassthrough { + t.Fatalf("passthrough => %v, want %v", w.V, DisableImageGenerationPassthrough) + } + } } func TestDisableImageGenerationMode_UnmarshalJSON(t *testing.T) { @@ -73,4 +83,14 @@ func TestDisableImageGenerationMode_UnmarshalJSON(t *testing.T) { t.Fatalf("chat => %v, want %v", v, DisableImageGenerationChat) } } + + { + var v DisableImageGenerationMode + if err := json.Unmarshal([]byte(`"passthrough"`), &v); err != nil { + t.Fatalf("unmarshal passthrough: %v", err) + } + if v != DisableImageGenerationPassthrough { + t.Fatalf("passthrough => %v, want %v", v, DisableImageGenerationPassthrough) + } + } } diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go index d7a49e9d4..226d6f72c 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -17,6 +17,8 @@ type SDKConfig struct { // and returns 404 for /v1/images/generations and /v1/images/edits. // - "chat": disable image_generation injection for all non-images endpoints (e.g. /v1/responses, /v1/chat/completions), // while keeping /v1/images/generations and /v1/images/edits enabled and preserving image_generation there. + // - "passthrough": do not modify the tool list on non-images endpoints — keep image_generation if the client + // sent it and do not inject it otherwise; on /v1/images/generations and /v1/images/edits behave like "chat". DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"` // GPTImage2BaseModel sets the base (mainline) model used when proxying GPT Image 2 diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index 33f53ca99..8f8434c82 100644 --- a/internal/runtime/executor/helps/payload_helpers.go +++ b/internal/runtime/executor/helps/payload_helpers.go @@ -33,11 +33,9 @@ func ApplyPayloadConfigWithRequest(cfg *config.Config, model, protocol, fromProt // Apply disable-image-generation filtering before payload rules so config payload // overrides can explicitly re-enable image_generation when desired. - if cfg.DisableImageGeneration != config.DisableImageGenerationOff { - if cfg.DisableImageGeneration != config.DisableImageGenerationChat || !isImagesEndpointRequestPath(requestPath) { - out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation") - out = removeToolChoiceFromPayloadWithRoot(out, root, "image_generation") - } + if shouldStripImageGeneration(cfg.DisableImageGeneration, requestPath) { + out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation") + out = removeToolChoiceFromPayloadWithRoot(out, root, "image_generation") } rules := cfg.Payload @@ -199,6 +197,23 @@ func isImagesEndpointRequestPath(path string) bool { return false } +// shouldStripImageGeneration reports whether the built-in image_generation tool must be +// removed from the outbound payload for the given mode and request path. +// - All: strip on every endpoint. +// - Chat: strip only on non-images endpoints; keep it on /v1/images/* endpoints. +// - Off / Passthrough: never strip. Off injects the tool elsewhere; Passthrough forwards +// the client payload untouched. +func shouldStripImageGeneration(mode config.DisableImageGenerationMode, requestPath string) bool { + switch mode { + case config.DisableImageGenerationAll: + return true + case config.DisableImageGenerationChat: + return !isImagesEndpointRequestPath(requestPath) + default: + return false + } +} + func payloadModelRulesMatch(rules []config.PayloadModelRule, protocol string, fromProtocol string, headers http.Header, payload []byte, root string, models []string) bool { if len(rules) == 0 || len(models) == 0 { return false diff --git a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go index a6627c838..fe6de37f6 100644 --- a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go +++ b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go @@ -97,6 +97,33 @@ func TestApplyPayloadConfigWithRoot_DisableImageGenerationChat_KeepsImageGenerat } } +func TestApplyPayloadConfigWithRoot_DisableImageGenerationPassthrough_KeepsPayloadUnchanged(t *testing.T) { + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationPassthrough}, + } + payload := []byte(`{"tools":[{"type":"image_generation"},{"type":"function","name":"f1"}],"tool_choice":{"type":"image_generation"}}`) + + // Passthrough must never inject or strip image_generation. The payload is forwarded as-is on + // non-images endpoints, and /v1/images/* endpoints behave like "chat" (also no removal). + for _, requestPath := range []string{"", "/v1/responses", "/v1/images/generations"} { + out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", requestPath) + + tools := gjson.GetBytes(out, "tools") + if !tools.Exists() || !tools.IsArray() { + t.Fatalf("path %q: expected tools array, got %v", requestPath, tools.Type) + } + if got := len(tools.Array()); got != 2 { + t.Fatalf("path %q: expected 2 tools (no removal), got %d", requestPath, got) + } + if got := tools.Array()[0].Get("type").String(); got != "image_generation" { + t.Fatalf("path %q: expected image_generation tool to be kept, got %q", requestPath, got) + } + if !gjson.GetBytes(out, "tool_choice").Exists() { + t.Fatalf("path %q: expected tool_choice to be kept", requestPath) + } + } +} + func TestApplyPayloadConfigWithRoot_DisableImageGeneration_PayloadOverrideCanRestoreImageGeneration(t *testing.T) { cfg := &config.Config{ SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll},