mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
Refactored `applyPayloadConfig` to `applyPayloadConfigWithRoot`, adding support for default rule validation against the original payload when available. Updated all executors to use `applyPayloadConfigWithRoot` and incorporate an optional original request payload for translations.
358 lines
11 KiB
Go
358 lines
11 KiB
Go
package executor
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
// ApplyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192))
|
|
// for standard Gemini format payloads. It normalizes the budget when the model supports thinking.
|
|
func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte {
|
|
// Use the alias from metadata if available, as it's registered in the global registry
|
|
// with thinking metadata; the upstream model name may not be registered.
|
|
lookupModel := util.ResolveOriginalModel(model, metadata)
|
|
|
|
// Determine which model to use for thinking support check.
|
|
// If the alias (lookupModel) is not in the registry, fall back to the upstream model.
|
|
thinkingModel := lookupModel
|
|
if !util.ModelSupportsThinking(lookupModel) && util.ModelSupportsThinking(model) {
|
|
thinkingModel = model
|
|
}
|
|
|
|
budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(thinkingModel, metadata)
|
|
if !ok || (budgetOverride == nil && includeOverride == nil) {
|
|
return payload
|
|
}
|
|
if !util.ModelSupportsThinking(thinkingModel) {
|
|
return payload
|
|
}
|
|
if budgetOverride != nil {
|
|
norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride)
|
|
budgetOverride = &norm
|
|
}
|
|
return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
|
|
}
|
|
|
|
// ApplyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., (high), (8192))
|
|
// for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking.
|
|
func ApplyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte {
|
|
// Use the alias from metadata if available, as it's registered in the global registry
|
|
// with thinking metadata; the upstream model name may not be registered.
|
|
lookupModel := util.ResolveOriginalModel(model, metadata)
|
|
|
|
// Determine which model to use for thinking support check.
|
|
// If the alias (lookupModel) is not in the registry, fall back to the upstream model.
|
|
thinkingModel := lookupModel
|
|
if !util.ModelSupportsThinking(lookupModel) && util.ModelSupportsThinking(model) {
|
|
thinkingModel = model
|
|
}
|
|
|
|
budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(thinkingModel, metadata)
|
|
if !ok || (budgetOverride == nil && includeOverride == nil) {
|
|
return payload
|
|
}
|
|
if !util.ModelSupportsThinking(thinkingModel) {
|
|
return payload
|
|
}
|
|
if budgetOverride != nil {
|
|
norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride)
|
|
budgetOverride = &norm
|
|
}
|
|
return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride)
|
|
}
|
|
|
|
// ApplyReasoningEffortMetadata applies reasoning effort overrides from metadata to the given JSON path.
|
|
// Metadata values take precedence over any existing field when the model supports thinking, intentionally
|
|
// overwriting caller-provided values to honor suffix/default metadata priority.
|
|
func ApplyReasoningEffortMetadata(payload []byte, metadata map[string]any, model, field string, allowCompat bool) []byte {
|
|
if len(metadata) == 0 {
|
|
return payload
|
|
}
|
|
if field == "" {
|
|
return payload
|
|
}
|
|
baseModel := util.ResolveOriginalModel(model, metadata)
|
|
if baseModel == "" {
|
|
baseModel = model
|
|
}
|
|
if !util.ModelSupportsThinking(baseModel) && !allowCompat {
|
|
return payload
|
|
}
|
|
if effort, ok := util.ReasoningEffortFromMetadata(metadata); ok && effort != "" {
|
|
if util.ModelUsesThinkingLevels(baseModel) || allowCompat {
|
|
if updated, err := sjson.SetBytes(payload, field, effort); err == nil {
|
|
return updated
|
|
}
|
|
}
|
|
}
|
|
// Fallback: numeric thinking_budget suffix for level-based (OpenAI-style) models.
|
|
if util.ModelUsesThinkingLevels(baseModel) || allowCompat {
|
|
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
|
|
if effort, ok := util.ThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" {
|
|
if updated, err := sjson.SetBytes(payload, field, effort); err == nil {
|
|
return updated
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return 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. Defaults are checked
|
|
// against the original payload when provided.
|
|
func applyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []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
|
|
source := original
|
|
if len(source) == 0 {
|
|
source = payload
|
|
}
|
|
appliedDefaults := make(map[string]struct{})
|
|
// 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(source, fullPath).Exists() {
|
|
continue
|
|
}
|
|
if _, ok := appliedDefaults[fullPath]; ok {
|
|
continue
|
|
}
|
|
updated, errSet := sjson.SetBytes(out, fullPath, value)
|
|
if errSet != nil {
|
|
continue
|
|
}
|
|
out = updated
|
|
appliedDefaults[fullPath] = struct{}{}
|
|
}
|
|
}
|
|
// 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)
|
|
}
|
|
|
|
// NormalizeThinkingConfig normalizes thinking-related fields in the payload
|
|
// based on model capabilities. For models without thinking support, it strips
|
|
// reasoning fields. For models with level-based thinking, it validates and
|
|
// normalizes the reasoning effort level. For models with numeric budget thinking,
|
|
// it strips the effort string fields.
|
|
func NormalizeThinkingConfig(payload []byte, model string, allowCompat bool) []byte {
|
|
if len(payload) == 0 || model == "" {
|
|
return payload
|
|
}
|
|
|
|
if !util.ModelSupportsThinking(model) {
|
|
if allowCompat {
|
|
return payload
|
|
}
|
|
return StripThinkingFields(payload, false)
|
|
}
|
|
|
|
if util.ModelUsesThinkingLevels(model) {
|
|
return NormalizeReasoningEffortLevel(payload, model)
|
|
}
|
|
|
|
// Model supports thinking but uses numeric budgets, not levels.
|
|
// Strip effort string fields since they are not applicable.
|
|
return StripThinkingFields(payload, true)
|
|
}
|
|
|
|
// StripThinkingFields removes thinking-related fields from the payload for
|
|
// models that do not support thinking. If effortOnly is true, only removes
|
|
// effort string fields (for models using numeric budgets).
|
|
func StripThinkingFields(payload []byte, effortOnly bool) []byte {
|
|
fieldsToRemove := []string{
|
|
"reasoning_effort",
|
|
"reasoning.effort",
|
|
}
|
|
if !effortOnly {
|
|
fieldsToRemove = append([]string{"reasoning", "thinking"}, fieldsToRemove...)
|
|
}
|
|
out := payload
|
|
for _, field := range fieldsToRemove {
|
|
if gjson.GetBytes(out, field).Exists() {
|
|
out, _ = sjson.DeleteBytes(out, field)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NormalizeReasoningEffortLevel validates and normalizes the reasoning_effort
|
|
// or reasoning.effort field for level-based thinking models.
|
|
func NormalizeReasoningEffortLevel(payload []byte, model string) []byte {
|
|
out := payload
|
|
|
|
if effort := gjson.GetBytes(out, "reasoning_effort"); effort.Exists() {
|
|
if normalized, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); ok {
|
|
out, _ = sjson.SetBytes(out, "reasoning_effort", normalized)
|
|
}
|
|
}
|
|
|
|
if effort := gjson.GetBytes(out, "reasoning.effort"); effort.Exists() {
|
|
if normalized, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); ok {
|
|
out, _ = sjson.SetBytes(out, "reasoning.effort", normalized)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// ValidateThinkingConfig checks for unsupported reasoning levels on level-based models.
|
|
// Returns a statusErr with 400 when an unsupported level is supplied to avoid silently
|
|
// downgrading requests.
|
|
func ValidateThinkingConfig(payload []byte, model string) error {
|
|
if len(payload) == 0 || model == "" {
|
|
return nil
|
|
}
|
|
if !util.ModelSupportsThinking(model) || !util.ModelUsesThinkingLevels(model) {
|
|
return nil
|
|
}
|
|
|
|
levels := util.GetModelThinkingLevels(model)
|
|
checkField := func(path string) error {
|
|
if effort := gjson.GetBytes(payload, path); effort.Exists() {
|
|
if _, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); !ok {
|
|
return statusErr{
|
|
code: http.StatusBadRequest,
|
|
msg: fmt.Sprintf("unsupported reasoning effort level %q for model %s (supported: %s)", effort.String(), model, strings.Join(levels, ", ")),
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := checkField("reasoning_effort"); err != nil {
|
|
return err
|
|
}
|
|
if err := checkField("reasoning.effort"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|