diff --git a/config.example.yaml b/config.example.yaml index 067910c538..be958f6d47 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -118,6 +118,33 @@ nonstream-keepalive-interval: 0 # keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives. # bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent. +# Payload rewrite rules. +# - disabled: true skips an entire rule without deleting it. +# - disabled-params keeps parameters in YAML but prevents them from being applied. +# payload: +# default: +# - disabled: false +# models: +# - name: "gpt-*" +# protocol: "openai" +# params: +# temperature: 0.7 +# top_p: 0.9 +# disabled-params: +# - top_p +# override: +# - disabled: true +# models: +# - name: "gemini-*" +# params: +# candidate_count: 1 +# filter: +# - disabled: false +# models: +# - name: "claude-*" +# params: +# - metadata.trace_id +# # Signature cache validation for thinking blocks (Antigravity/Claude). # When true (default), cached signatures are preferred and validated. # When false, client signatures are used directly after normalization (bypass mode for testing). diff --git a/internal/config/config.go b/internal/config/config.go index a3dd4c597a..1d91fbf71d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -301,19 +301,27 @@ type PayloadConfig struct { // PayloadFilterRule describes a rule to remove specific JSON paths from matching model payloads. type PayloadFilterRule struct { + // Disabled skips this rule entirely when true. + Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` // Models lists model entries with name pattern and protocol constraint. Models []PayloadModelRule `yaml:"models" json:"models"` // Params lists JSON paths (gjson/sjson syntax) to remove from the payload. Params []string `yaml:"params" json:"params"` + // DisabledParams lists JSON paths that should remain visible in config but not be applied. + DisabledParams []string `yaml:"disabled-params,omitempty" json:"disabled-params,omitempty"` } // PayloadRule describes a single rule targeting a list of models with parameter updates. type PayloadRule struct { + // Disabled skips this rule entirely when true. + Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` // Models lists model entries with name pattern and protocol constraint. Models []PayloadModelRule `yaml:"models" json:"models"` // Params maps JSON paths (gjson/sjson syntax) to values written into the payload. // For *-raw rules, values are treated as raw JSON fragments (strings are used as-is). Params map[string]any `yaml:"params" json:"params"` + // DisabledParams lists JSON paths that should remain visible in config but not be applied. + DisabledParams []string `yaml:"disabled-params,omitempty" json:"disabled-params,omitempty"` } // PayloadModelRule ties a model name pattern to a specific translator protocol. @@ -720,8 +728,16 @@ func sanitizePayloadRawRules(rules []PayloadRule, section string) []PayloadRule if len(rule.Params) == 0 { continue } + if rule.Disabled { + out = append(out, rule) + continue + } + disabledParams := payloadDisabledParamSet(rule.DisabledParams) invalid := false for path, value := range rule.Params { + if payloadParamDisabled(disabledParams, path) { + continue + } raw, ok := payloadRawString(value) if !ok { continue @@ -745,6 +761,32 @@ func sanitizePayloadRawRules(rules []PayloadRule, section string) []PayloadRule return out } +func payloadDisabledParamSet(paths []string) map[string]struct{} { + if len(paths) == 0 { + return nil + } + out := make(map[string]struct{}, len(paths)) + for _, path := range paths { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + continue + } + out[trimmed] = struct{}{} + } + if len(out) == 0 { + return nil + } + return out +} + +func payloadParamDisabled(disabled map[string]struct{}, path string) bool { + if len(disabled) == 0 { + return false + } + _, ok := disabled[strings.TrimSpace(path)] + return ok +} + func payloadRawString(value any) ([]byte, bool) { switch typed := value.(type) { case string: diff --git a/internal/config/payload_sanitize_test.go b/internal/config/payload_sanitize_test.go new file mode 100644 index 0000000000..c5ae8505a6 --- /dev/null +++ b/internal/config/payload_sanitize_test.go @@ -0,0 +1,49 @@ +package config + +import "testing" + +func TestSanitizePayloadRules_KeepsDisabledRawRuleWithInvalidJSON(t *testing.T) { + cfg := &Config{ + Payload: PayloadConfig{ + DefaultRaw: []PayloadRule{ + { + Disabled: true, + Models: []PayloadModelRule{{Name: "gpt-*"}}, + Params: map[string]any{ + "metadata": `{"enabled":`, + }, + }, + }, + }, + } + + cfg.SanitizePayloadRules() + + if len(cfg.Payload.DefaultRaw) != 1 { + t.Fatalf("disabled raw rule should be preserved during sanitize, got %d rules", len(cfg.Payload.DefaultRaw)) + } + if !cfg.Payload.DefaultRaw[0].Disabled { + t.Fatalf("disabled raw rule should remain disabled after sanitize") + } +} + +func TestSanitizePayloadRules_DropsEnabledRawRuleWithInvalidJSON(t *testing.T) { + cfg := &Config{ + Payload: PayloadConfig{ + DefaultRaw: []PayloadRule{ + { + Models: []PayloadModelRule{{Name: "gpt-*"}}, + Params: map[string]any{ + "metadata": `{"enabled":`, + }, + }, + }, + }, + } + + cfg.SanitizePayloadRules() + + if len(cfg.Payload.DefaultRaw) != 0 { + t.Fatalf("enabled raw rule with invalid JSON should be dropped, got %d rules", len(cfg.Payload.DefaultRaw)) + } +} diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index 73514c2dd1..fbb80cf185 100644 --- a/internal/runtime/executor/helps/payload_helpers.go +++ b/internal/runtime/executor/helps/payload_helpers.go @@ -39,10 +39,14 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string // Apply default rules: first write wins per field across all matching rules. for i := range rules.Default { rule := &rules.Default[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + if rule.Disabled || !payloadModelRulesMatch(rule.Models, protocol, candidates) { continue } + disabledParams := payloadDisabledParamSet(rule.DisabledParams) for path, value := range rule.Params { + if payloadParamDisabled(disabledParams, path) { + continue + } fullPath := buildPayloadPath(root, path) if fullPath == "" { continue @@ -64,10 +68,14 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string // Apply default raw rules: first write wins per field across all matching rules. for i := range rules.DefaultRaw { rule := &rules.DefaultRaw[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + if rule.Disabled || !payloadModelRulesMatch(rule.Models, protocol, candidates) { continue } + disabledParams := payloadDisabledParamSet(rule.DisabledParams) for path, value := range rule.Params { + if payloadParamDisabled(disabledParams, path) { + continue + } fullPath := buildPayloadPath(root, path) if fullPath == "" { continue @@ -93,10 +101,14 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string // Apply override rules: last write wins per field across all matching rules. for i := range rules.Override { rule := &rules.Override[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + if rule.Disabled || !payloadModelRulesMatch(rule.Models, protocol, candidates) { continue } + disabledParams := payloadDisabledParamSet(rule.DisabledParams) for path, value := range rule.Params { + if payloadParamDisabled(disabledParams, path) { + continue + } fullPath := buildPayloadPath(root, path) if fullPath == "" { continue @@ -111,10 +123,14 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string // Apply override raw rules: last write wins per field across all matching rules. for i := range rules.OverrideRaw { rule := &rules.OverrideRaw[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + if rule.Disabled || !payloadModelRulesMatch(rule.Models, protocol, candidates) { continue } + disabledParams := payloadDisabledParamSet(rule.DisabledParams) for path, value := range rule.Params { + if payloadParamDisabled(disabledParams, path) { + continue + } fullPath := buildPayloadPath(root, path) if fullPath == "" { continue @@ -133,10 +149,14 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string // Apply filter rules: remove matching paths from payload. for i := range rules.Filter { rule := &rules.Filter[i] - if !payloadModelRulesMatch(rule.Models, protocol, candidates) { + if rule.Disabled || !payloadModelRulesMatch(rule.Models, protocol, candidates) { continue } + disabledParams := payloadDisabledParamSet(rule.DisabledParams) for _, path := range rule.Params { + if payloadParamDisabled(disabledParams, path) { + continue + } fullPath := buildPayloadPath(root, path) if fullPath == "" { continue @@ -151,6 +171,32 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string return out } +func payloadDisabledParamSet(paths []string) map[string]struct{} { + if len(paths) == 0 { + return nil + } + out := make(map[string]struct{}, len(paths)) + for _, path := range paths { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + continue + } + out[trimmed] = struct{}{} + } + if len(out) == 0 { + return nil + } + return out +} + +func payloadParamDisabled(disabled map[string]struct{}, path string) bool { + if len(disabled) == 0 { + return false + } + _, ok := disabled[strings.TrimSpace(path)] + return ok +} + func payloadModelRulesMatch(rules []config.PayloadModelRule, protocol string, models []string) bool { if len(rules) == 0 || len(models) == 0 { return false diff --git a/internal/runtime/executor/payload_helpers_test.go b/internal/runtime/executor/payload_helpers_test.go new file mode 100644 index 0000000000..ac2354d44f --- /dev/null +++ b/internal/runtime/executor/payload_helpers_test.go @@ -0,0 +1,191 @@ +package executor + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" + "github.com/tidwall/gjson" +) + +func TestApplyPayloadConfigWithRoot_SkipsDisabledDefaultRule(t *testing.T) { + cfg := &config.Config{ + Payload: config.PayloadConfig{ + Default: []config.PayloadRule{ + { + Disabled: true, + Models: []config.PayloadModelRule{{Name: "gpt-*"}}, + Params: map[string]any{"temperature": 0.7}, + }, + }, + }, + } + + payload := []byte(`{"model":"gpt-4o","messages":[]}`) + got := helps.ApplyPayloadConfigWithRoot(cfg, "gpt-4o", "", "", payload, payload, "") + if gjson.GetBytes(got, "temperature").Exists() { + t.Fatalf("disabled default rule should not write temperature, got %s", got) + } +} + +func TestApplyPayloadConfigWithRoot_SkipsDisabledOverrideParams(t *testing.T) { + cfg := &config.Config{ + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{ + { + Models: []config.PayloadModelRule{{Name: "gpt-*"}}, + Params: map[string]any{"temperature": 0.7, "top_p": 0.9}, + DisabledParams: []string{"top_p"}, + }, + }, + }, + } + + payload := []byte(`{"model":"gpt-4o","messages":[]}`) + got := helps.ApplyPayloadConfigWithRoot(cfg, "gpt-4o", "", "", payload, payload, "") + + if value := gjson.GetBytes(got, "temperature"); !value.Exists() || value.Float() != 0.7 { + t.Fatalf("enabled param should be written, got %s", got) + } + if gjson.GetBytes(got, "top_p").Exists() { + t.Fatalf("disabled param should not be written, got %s", got) + } +} + +func TestApplyPayloadConfigWithRoot_SkipsDisabledDefaultRawParams(t *testing.T) { + cfg := &config.Config{ + Payload: config.PayloadConfig{ + DefaultRaw: []config.PayloadRule{ + { + Models: []config.PayloadModelRule{{Name: "gpt-*"}}, + Params: map[string]any{ + "metadata": `{"enabled":true}`, + "reasoning": `{"budget_tokens":1024}`, + }, + DisabledParams: []string{"metadata"}, + }, + }, + }, + } + + payload := []byte(`{"model":"gpt-4o","messages":[]}`) + got := helps.ApplyPayloadConfigWithRoot(cfg, "gpt-4o", "", "", payload, payload, "") + + if gjson.GetBytes(got, "metadata").Exists() { + t.Fatalf("disabled raw param should not be written, got %s", got) + } + if value := gjson.GetBytes(got, "reasoning.budget_tokens"); !value.Exists() || value.Int() != 1024 { + t.Fatalf("enabled raw param should be written, got %s", got) + } +} + +func TestApplyPayloadConfigWithRoot_SkipsDisabledFilterRule(t *testing.T) { + cfg := &config.Config{ + Payload: config.PayloadConfig{ + Filter: []config.PayloadFilterRule{ + { + Disabled: true, + Models: []config.PayloadModelRule{{Name: "gpt-*"}}, + Params: []string{"temperature"}, + }, + { + Models: []config.PayloadModelRule{{Name: "gpt-*"}}, + Params: []string{"top_p"}, + }, + }, + }, + } + + payload := []byte(`{"model":"gpt-4o","messages":[],"temperature":0.7,"top_p":0.9}`) + got := helps.ApplyPayloadConfigWithRoot(cfg, "gpt-4o", "", "", payload, payload, "") + + if !gjson.GetBytes(got, "temperature").Exists() { + t.Fatalf("disabled filter rule should keep temperature, got %s", got) + } + if gjson.GetBytes(got, "top_p").Exists() { + t.Fatalf("enabled filter rule should remove top_p, got %s", got) + } +} + +func TestApplyPayloadConfigWithRoot_SkipsDisabledFilterParams(t *testing.T) { + cfg := &config.Config{ + Payload: config.PayloadConfig{ + Filter: []config.PayloadFilterRule{ + { + Models: []config.PayloadModelRule{{Name: "gpt-*"}}, + Params: []string{"temperature", "top_p"}, + DisabledParams: []string{"temperature"}, + }, + }, + }, + } + + payload := []byte(`{"model":"gpt-4o","messages":[],"temperature":0.7,"top_p":0.9}`) + got := helps.ApplyPayloadConfigWithRoot(cfg, "gpt-4o", "", "", payload, payload, "") + + if !gjson.GetBytes(got, "temperature").Exists() { + t.Fatalf("disabled filter param should keep temperature, got %s", got) + } + if gjson.GetBytes(got, "top_p").Exists() { + t.Fatalf("enabled filter param should remove top_p, got %s", got) + } +} + +func TestApplyPayloadConfigWithRoot_KeepsRuleWhenDisabledRawParamHasInvalidJSON(t *testing.T) { + cfg := &config.Config{ + Payload: config.PayloadConfig{ + DefaultRaw: []config.PayloadRule{ + { + Models: []config.PayloadModelRule{{Name: "gpt-*"}}, + Params: map[string]any{ + "metadata": `{"enabled":`, + "reasoning": `{"budget_tokens":1024}`, + }, + DisabledParams: []string{" metadata "}, + }, + }, + }, + } + + cfg.SanitizePayloadRules() + if len(cfg.Payload.DefaultRaw) != 1 { + t.Fatalf("disabled invalid raw param should not drop the whole rule, got %d rules", len(cfg.Payload.DefaultRaw)) + } + + payload := []byte(`{"model":"gpt-4o","messages":[]}`) + got := helps.ApplyPayloadConfigWithRoot(cfg, "gpt-4o", "", "", payload, payload, "") + + if gjson.GetBytes(got, "metadata").Exists() { + t.Fatalf("disabled invalid raw param should not be written, got %s", got) + } + if value := gjson.GetBytes(got, "reasoning.budget_tokens"); !value.Exists() || value.Int() != 1024 { + t.Fatalf("enabled raw param should still be written after sanitize, got %s", got) + } +} + +func TestApplyPayloadConfigWithRoot_DropsRuleWhenEnabledRawParamHasInvalidJSON(t *testing.T) { + cfg := &config.Config{ + Payload: config.PayloadConfig{ + DefaultRaw: []config.PayloadRule{ + { + Models: []config.PayloadModelRule{{Name: "gpt-*"}}, + Params: map[string]any{ + "metadata": `{"enabled":`, + "reasoning": `{"budget_tokens":1024}`, + }, + }, + }, + }, + } + + cfg.SanitizePayloadRules() + if len(cfg.Payload.DefaultRaw) != 0 { + t.Fatalf("enabled invalid raw param should still drop the rule, got %d rules", len(cfg.Payload.DefaultRaw)) + } + + payload := []byte(`{"model":"gpt-4o","messages":[]}`) + got := helps.ApplyPayloadConfigWithRoot(cfg, "gpt-4o", "", "", payload, payload, "") + if gjson.GetBytes(got, "reasoning").Exists() { + t.Fatalf("dropped rule should not write any raw params, got %s", got) + } +}