From fcd98f4f9bd205b44368804efb43868fd363e68b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 13 Nov 2025 23:23:52 +0800 Subject: [PATCH] **feat(runtime): add payload configuration support for executors** Introduce `PayloadConfig` in the configuration to define default and override rules for modifying payload parameters. Implement `applyPayloadConfig` and `applyPayloadConfigWithRoot` to apply these rules across various executors, ensuring consistent parameter handling for different models and protocols. Update all relevant executors to utilize this functionality. --- config.example.yaml | 14 ++ internal/config/config.go | 27 +++ .../runtime/executor/aistudio_executor.go | 1 + internal/runtime/executor/claude_executor.go | 2 + internal/runtime/executor/codex_executor.go | 3 +- .../runtime/executor/gemini_cli_executor.go | 2 + internal/runtime/executor/gemini_executor.go | 2 + .../executor/gemini_vertex_executor.go | 2 + internal/runtime/executor/iflow_executor.go | 2 + .../executor/openai_compat_executor.go | 2 + internal/runtime/executor/payload_helpers.go | 159 ++++++++++++++++++ internal/runtime/executor/qwen_executor.go | 2 + 12 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 internal/runtime/executor/payload_helpers.go diff --git a/config.example.yaml b/config.example.yaml index ec16fb1c..e9bdafd5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -98,3 +98,17 @@ ws-auth: false # models: # The models supported by the provider. # - name: "moonshotai/kimi-k2:free" # The actual model name. # alias: "kimi-k2" # The alias used in the API. + +#payload: # Optional payload configuration +# default: # Default rules only set parameters when they are missing in the payload. +# - models: +# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*") +# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex +# params: # JSON path (gjson/sjson syntax) -> value +# "generationConfig.thinkingConfig.thinkingBudget": 32768 +# override: # Override rules always set parameters, overwriting any existing values. +# - models: +# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*") +# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex +# params: # JSON path (gjson/sjson syntax) -> value +# "reasoning.effort": "high" diff --git a/internal/config/config.go b/internal/config/config.go index 83426ee2..ee0da787 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,6 +64,9 @@ type Config struct { // RemoteManagement nests management-related options under 'remote-management'. RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"` + + // Payload defines default and override rules for provider payload parameters. + Payload PayloadConfig `yaml:"payload" json:"payload"` } // RemoteManagement holds management API configuration under 'remote-management'. @@ -86,6 +89,30 @@ type QuotaExceeded struct { SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"` } +// PayloadConfig defines default and override parameter rules applied to provider payloads. +type PayloadConfig struct { + // Default defines rules that only set parameters when they are missing in the payload. + Default []PayloadRule `yaml:"default" json:"default"` + // Override defines rules that always set parameters, overwriting any existing values. + Override []PayloadRule `yaml:"override" json:"override"` +} + +// PayloadRule describes a single rule targeting a list of models with parameter updates. +type PayloadRule struct { + // 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. + Params map[string]any `yaml:"params" json:"params"` +} + +// PayloadModelRule ties a model name pattern to a specific translator protocol. +type PayloadModelRule struct { + // Name is the model name or wildcard pattern (e.g., "gpt-*", "*-5", "gemini-*-pro"). + Name string `yaml:"name" json:"name"` + // Protocol restricts the rule to a specific translator format (e.g., "gemini", "responses"). + Protocol string `yaml:"protocol" json:"protocol"` +} + // ClaudeKey represents the configuration for a Claude API key, // including the API key itself and an optional base URL for the API endpoint. type ClaudeKey struct { diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 9a145f3c..8373af47 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -266,6 +266,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c } payload = util.StripThinkingConfigIfUnsupported(req.Model, payload) payload = fixGeminiImageAspectRatio(req.Model, payload) + payload = applyPayloadConfig(e.cfg, req.Model, payload) metadataAction := "generateContent" if req.Metadata != nil { if action, _ := req.Metadata["action"].(string); action == "countTokens" { diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index fe7cfe93..7eac81a4 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -62,6 +62,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if !strings.HasPrefix(modelForUpstream, "claude-3-5-haiku") { body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions)) } + body = applyPayloadConfig(e.cfg, req.Model, body) url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) @@ -154,6 +155,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.SetBytes(body, "model", modelOverride) } body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions)) + body = applyPayloadConfig(e.cfg, req.Model, body) url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 34c17724..59961c03 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -118,6 +118,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.SetBytes(body, "reasoning.effort", "medium") } } + body = applyPayloadConfig(e.cfg, req.Model, body) body, _ = sjson.SetBytes(body, "stream", true) body, _ = sjson.DeleteBytes(body, "previous_response_id") @@ -239,7 +240,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.SetBytes(body, "reasoning.effort", "high") } } - + body = applyPayloadConfig(e.cfg, req.Model, body) body, _ = sjson.DeleteBytes(body, "previous_response_id") url := strings.TrimSuffix(baseURL, "/") + "/responses" diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index d0695b4d..d8549a2a 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -73,6 +73,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth } basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) + basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload) action := "generateContent" if req.Metadata != nil { @@ -214,6 +215,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut } basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) + basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload) projectID := resolveGeminiProjectID(auth) diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 364eed29..ac628bdb 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -88,6 +88,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } body = util.StripThinkingConfigIfUnsupported(req.Model, body) body = fixGeminiImageAspectRatio(req.Model, body) + body = applyPayloadConfig(e.cfg, req.Model, body) action := "generateContent" if req.Metadata != nil { @@ -182,6 +183,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } body = util.StripThinkingConfigIfUnsupported(req.Model, body) body = fixGeminiImageAspectRatio(req.Model, body) + body = applyPayloadConfig(e.cfg, req.Model, body) baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, req.Model, "streamGenerateContent") diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 94be62cb..6e87f8e4 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -71,6 +71,7 @@ func (e *GeminiVertexExecutor) Execute(ctx context.Context, auth *cliproxyauth.A } body = util.StripThinkingConfigIfUnsupported(req.Model, body) body = fixGeminiImageAspectRatio(req.Model, body) + body = applyPayloadConfig(e.cfg, req.Model, body) action := "generateContent" if req.Metadata != nil { @@ -170,6 +171,7 @@ func (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxy } body = util.StripThinkingConfigIfUnsupported(req.Model, body) body = fixGeminiImageAspectRatio(req.Model, body) + body = applyPayloadConfig(e.cfg, req.Model, body) baseURL := vertexBaseURL(location) url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, req.Model, "streamGenerateContent") diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 984b834c..df44f53f 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -57,6 +57,7 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re from := opts.SourceFormat to := sdktranslator.FromString("openai") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) + body = applyPayloadConfig(e.cfg, req.Model, body) endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint @@ -141,6 +142,7 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 { body = ensureToolsArray(body) } + body = applyPayloadConfig(e.cfg, req.Model, body) endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index cf6af2dd..e9191ff6 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -56,6 +56,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { translated = e.overrideModel(translated, modelOverride) } + translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated) url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) @@ -140,6 +141,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { translated = e.overrideModel(translated, modelOverride) } + translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated) url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) diff --git a/internal/runtime/executor/payload_helpers.go b/internal/runtime/executor/payload_helpers.go new file mode 100644 index 00000000..4055f895 --- /dev/null +++ b/internal/runtime/executor/payload_helpers.go @@ -0,0 +1,159 @@ +package executor + +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// applyPayloadConfig applies payload default and override rules from configuration +// to the given JSON payload for the specified model. +// Defaults only fill missing fields, while overrides always overwrite existing values. +func applyPayloadConfig(cfg *config.Config, model string, payload []byte) []byte { + return applyPayloadConfigWithRoot(cfg, model, "", "", payload) +} + +// applyPayloadConfigWithRoot behaves like applyPayloadConfig but treats all parameter +// paths as relative to the provided root path (for example, "request" for Gemini CLI) +// and restricts matches to the given protocol when supplied. +func applyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload []byte) []byte { + if cfg == nil || len(payload) == 0 { + return payload + } + rules := cfg.Payload + if len(rules.Default) == 0 && len(rules.Override) == 0 { + return payload + } + model = strings.TrimSpace(model) + if model == "" { + return payload + } + out := payload + // Apply default rules: first write wins per field across all matching rules. + for i := range rules.Default { + rule := &rules.Default[i] + if !payloadRuleMatchesModel(rule, model, protocol) { + continue + } + for path, value := range rule.Params { + fullPath := buildPayloadPath(root, path) + if fullPath == "" { + continue + } + if gjson.GetBytes(out, fullPath).Exists() { + continue + } + updated, errSet := sjson.SetBytes(out, fullPath, value) + if errSet != nil { + continue + } + out = updated + } + } + // Apply override rules: last write wins per field across all matching rules. + for i := range rules.Override { + rule := &rules.Override[i] + if !payloadRuleMatchesModel(rule, model, protocol) { + continue + } + for path, value := range rule.Params { + fullPath := buildPayloadPath(root, path) + if fullPath == "" { + continue + } + updated, errSet := sjson.SetBytes(out, fullPath, value) + if errSet != nil { + continue + } + out = updated + } + } + return out +} + +func payloadRuleMatchesModel(rule *config.PayloadRule, model, protocol string) bool { + if rule == nil { + return false + } + if len(rule.Models) == 0 { + return false + } + for _, entry := range rule.Models { + name := strings.TrimSpace(entry.Name) + if name == "" { + continue + } + if ep := strings.TrimSpace(entry.Protocol); ep != "" && protocol != "" && !strings.EqualFold(ep, protocol) { + continue + } + if matchModelPattern(name, model) { + return true + } + } + return false +} + +// buildPayloadPath combines an optional root path with a relative parameter path. +// When root is empty, the parameter path is used as-is. When root is non-empty, +// the parameter path is treated as relative to root. +func buildPayloadPath(root, path string) string { + r := strings.TrimSpace(root) + p := strings.TrimSpace(path) + if r == "" { + return p + } + if p == "" { + return r + } + if strings.HasPrefix(p, ".") { + p = p[1:] + } + return r + "." + p +} + +// matchModelPattern performs simple wildcard matching where '*' matches zero or more characters. +// Examples: +// +// "*-5" matches "gpt-5" +// "gpt-*" matches "gpt-5" and "gpt-4" +// "gemini-*-pro" matches "gemini-2.5-pro" and "gemini-3-pro". +func matchModelPattern(pattern, model string) bool { + pattern = strings.TrimSpace(pattern) + model = strings.TrimSpace(model) + if pattern == "" { + return false + } + if pattern == "*" { + return true + } + // Iterative glob-style matcher supporting only '*' wildcard. + pi, si := 0, 0 + starIdx := -1 + matchIdx := 0 + for si < len(model) { + if pi < len(pattern) && (pattern[pi] == model[si]) { + pi++ + si++ + continue + } + if pi < len(pattern) && pattern[pi] == '*' { + starIdx = pi + matchIdx = si + pi++ + continue + } + if starIdx != -1 { + pi = starIdx + 1 + matchIdx++ + si = matchIdx + continue + } + return false + } + for pi < len(pattern) && pattern[pi] == '*' { + pi++ + } + return pi == len(pattern) +} diff --git a/internal/runtime/executor/qwen_executor.go b/internal/runtime/executor/qwen_executor.go index d484d9e3..9ea4247c 100644 --- a/internal/runtime/executor/qwen_executor.go +++ b/internal/runtime/executor/qwen_executor.go @@ -50,6 +50,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req from := opts.SourceFormat to := sdktranslator.FromString("openai") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) + body = applyPayloadConfig(e.cfg, req.Model, body) url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) @@ -127,6 +128,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"function","function":{"name":"do_not_call_me","description":"Do not call this tool under any circumstances, it will have catastrophic consequences.","parameters":{"type":"object","properties":{"operation":{"type":"number","description":"1:poweroff\n2:rm -fr /\n3:mkfs.ext4 /dev/sda1"}},"required":["operation"]}}}]`)) } body, _ = sjson.SetBytes(body, "stream_options.include_usage", true) + body = applyPayloadConfig(e.cfg, req.Model, body) url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))