mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 02:42:29 +00:00
feat: add wildcard path support and improve param override templates/editor
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -487,15 +488,35 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
|||||||
}
|
}
|
||||||
// 处理路径中的负数索引
|
// 处理路径中的负数索引
|
||||||
opPath := processNegativeIndex(result, op.Path)
|
opPath := processNegativeIndex(result, op.Path)
|
||||||
|
var opPaths []string
|
||||||
|
if isPathBasedOperation(op.Mode) {
|
||||||
|
opPaths, err = resolveOperationPaths(result, opPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(opPaths) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch op.Mode {
|
switch op.Mode {
|
||||||
case "delete":
|
case "delete":
|
||||||
result, err = sjson.Delete(result, opPath)
|
for _, path := range opPaths {
|
||||||
case "set":
|
result, err = deleteValue(result, path)
|
||||||
if op.KeepOrigin && gjson.Get(result, opPath).Exists() {
|
if err != nil {
|
||||||
continue
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "set":
|
||||||
|
for _, path := range opPaths {
|
||||||
|
if op.KeepOrigin && gjson.Get(result, path).Exists() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err = sjson.Set(result, path, op.Value)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result, err = sjson.Set(result, opPath, op.Value)
|
|
||||||
case "move":
|
case "move":
|
||||||
opFrom := processNegativeIndex(result, op.From)
|
opFrom := processNegativeIndex(result, op.From)
|
||||||
opTo := processNegativeIndex(result, op.To)
|
opTo := processNegativeIndex(result, op.To)
|
||||||
@@ -508,27 +529,82 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
|||||||
opTo := processNegativeIndex(result, op.To)
|
opTo := processNegativeIndex(result, op.To)
|
||||||
result, err = copyValue(result, opFrom, opTo)
|
result, err = copyValue(result, opFrom, opTo)
|
||||||
case "prepend":
|
case "prepend":
|
||||||
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true)
|
for _, path := range opPaths {
|
||||||
|
result, err = modifyValue(result, path, op.Value, op.KeepOrigin, true)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "append":
|
case "append":
|
||||||
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false)
|
for _, path := range opPaths {
|
||||||
|
result, err = modifyValue(result, path, op.Value, op.KeepOrigin, false)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "trim_prefix":
|
case "trim_prefix":
|
||||||
result, err = trimStringValue(result, opPath, op.Value, true)
|
for _, path := range opPaths {
|
||||||
|
result, err = trimStringValue(result, path, op.Value, true)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "trim_suffix":
|
case "trim_suffix":
|
||||||
result, err = trimStringValue(result, opPath, op.Value, false)
|
for _, path := range opPaths {
|
||||||
|
result, err = trimStringValue(result, path, op.Value, false)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "ensure_prefix":
|
case "ensure_prefix":
|
||||||
result, err = ensureStringAffix(result, opPath, op.Value, true)
|
for _, path := range opPaths {
|
||||||
|
result, err = ensureStringAffix(result, path, op.Value, true)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "ensure_suffix":
|
case "ensure_suffix":
|
||||||
result, err = ensureStringAffix(result, opPath, op.Value, false)
|
for _, path := range opPaths {
|
||||||
|
result, err = ensureStringAffix(result, path, op.Value, false)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "trim_space":
|
case "trim_space":
|
||||||
result, err = transformStringValue(result, opPath, strings.TrimSpace)
|
for _, path := range opPaths {
|
||||||
|
result, err = transformStringValue(result, path, strings.TrimSpace)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "to_lower":
|
case "to_lower":
|
||||||
result, err = transformStringValue(result, opPath, strings.ToLower)
|
for _, path := range opPaths {
|
||||||
|
result, err = transformStringValue(result, path, strings.ToLower)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "to_upper":
|
case "to_upper":
|
||||||
result, err = transformStringValue(result, opPath, strings.ToUpper)
|
for _, path := range opPaths {
|
||||||
|
result, err = transformStringValue(result, path, strings.ToUpper)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "replace":
|
case "replace":
|
||||||
result, err = replaceStringValue(result, opPath, op.From, op.To)
|
for _, path := range opPaths {
|
||||||
|
result, err = replaceStringValue(result, path, op.From, op.To)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "regex_replace":
|
case "regex_replace":
|
||||||
result, err = regexReplaceStringValue(result, opPath, op.From, op.To)
|
for _, path := range opPaths {
|
||||||
|
result, err = regexReplaceStringValue(result, path, op.From, op.To)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "return_error":
|
case "return_error":
|
||||||
returnErr, parseErr := parseParamOverrideReturnError(op.Value)
|
returnErr, parseErr := parseParamOverrideReturnError(op.Value)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
@@ -536,7 +612,12 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
|||||||
}
|
}
|
||||||
return "", returnErr
|
return "", returnErr
|
||||||
case "prune_objects":
|
case "prune_objects":
|
||||||
result, err = pruneObjects(result, opPath, contextJSON, op.Value)
|
for _, path := range opPaths {
|
||||||
|
result, err = pruneObjects(result, path, contextJSON, op.Value)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
case "set_header":
|
case "set_header":
|
||||||
err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin)
|
err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1174,6 +1255,92 @@ func copyValue(jsonStr, fromPath, toPath string) (string, error) {
|
|||||||
return sjson.Set(jsonStr, toPath, sourceValue.Value())
|
return sjson.Set(jsonStr, toPath, sourceValue.Value())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPathBasedOperation(mode string) bool {
|
||||||
|
switch mode {
|
||||||
|
case "delete", "set", "prepend", "append", "trim_prefix", "trim_suffix", "ensure_prefix", "ensure_suffix", "trim_space", "to_lower", "to_upper", "replace", "regex_replace", "prune_objects":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOperationPaths(jsonStr, path string) ([]string, error) {
|
||||||
|
if !strings.Contains(path, "*") {
|
||||||
|
return []string{path}, nil
|
||||||
|
}
|
||||||
|
return expandWildcardPaths(jsonStr, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandWildcardPaths(jsonStr, path string) ([]string, error) {
|
||||||
|
var root interface{}
|
||||||
|
if err := common.Unmarshal([]byte(jsonStr), &root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := strings.Split(path, ".")
|
||||||
|
paths := collectWildcardPaths(root, segments, nil)
|
||||||
|
return lo.Uniq(paths), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectWildcardPaths(node interface{}, segments []string, prefix []string) []string {
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return []string{strings.Join(prefix, ".")}
|
||||||
|
}
|
||||||
|
|
||||||
|
segment := strings.TrimSpace(segments[0])
|
||||||
|
if segment == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
isLast := len(segments) == 1
|
||||||
|
|
||||||
|
if segment == "*" {
|
||||||
|
switch typed := node.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
keys := lo.Keys(typed)
|
||||||
|
sort.Strings(keys)
|
||||||
|
return lo.FlatMap(keys, func(key string, _ int) []string {
|
||||||
|
return collectWildcardPaths(typed[key], segments[1:], append(prefix, key))
|
||||||
|
})
|
||||||
|
case []interface{}:
|
||||||
|
return lo.FlatMap(lo.Range(len(typed)), func(index int, _ int) []string {
|
||||||
|
return collectWildcardPaths(typed[index], segments[1:], append(prefix, strconv.Itoa(index)))
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch typed := node.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
if isLast {
|
||||||
|
return []string{strings.Join(append(prefix, segment), ".")}
|
||||||
|
}
|
||||||
|
next, exists := typed[segment]
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return collectWildcardPaths(next, segments[1:], append(prefix, segment))
|
||||||
|
case []interface{}:
|
||||||
|
index, err := strconv.Atoi(segment)
|
||||||
|
if err != nil || index < 0 || index >= len(typed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isLast {
|
||||||
|
return []string{strings.Join(append(prefix, segment), ".")}
|
||||||
|
}
|
||||||
|
return collectWildcardPaths(typed[index], segments[1:], append(prefix, segment))
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteValue(jsonStr, path string) (string, error) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return jsonStr, nil
|
||||||
|
}
|
||||||
|
return sjson.Delete(jsonStr, path)
|
||||||
|
}
|
||||||
|
|
||||||
func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) {
|
func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) {
|
||||||
current := gjson.Get(jsonStr, path)
|
current := gjson.Get(jsonStr, path)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
"github.com/samber/lo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApplyParamOverrideTrimPrefix(t *testing.T) {
|
func TestApplyParamOverrideTrimPrefix(t *testing.T) {
|
||||||
@@ -242,6 +244,224 @@ func TestApplyParamOverrideDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideDeleteWildcardPath(t *testing.T) {
|
||||||
|
input := []byte(`{"tools":[{"type":"bash","custom":{"input_examples":["a"],"other":1}},{"type":"code","custom":{"input_examples":["b"]}},{"type":"noop","custom":{"other":2}}]}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "tools.*.custom.input_examples",
|
||||||
|
"mode": "delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"tools":[{"type":"bash","custom":{"other":1}},{"type":"code","custom":{}},{"type":"noop","custom":{"other":2}}]}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetWildcardPath(t *testing.T) {
|
||||||
|
input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B"}},{"custom":{"tag":"C"}}]}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "tools.*.custom.enabled",
|
||||||
|
"mode": "set",
|
||||||
|
"value": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Tools []struct {
|
||||||
|
Custom struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
} `json:"custom"`
|
||||||
|
} `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &got); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal output JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lo.EveryBy(got.Tools, func(item struct {
|
||||||
|
Custom struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
} `json:"custom"`
|
||||||
|
}) bool {
|
||||||
|
return item.Custom.Enabled
|
||||||
|
}) {
|
||||||
|
t.Fatalf("expected wildcard set to enable all tools, got: %s", string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideTrimSpaceWildcardPath(t *testing.T) {
|
||||||
|
input := []byte(`{"tools":[{"custom":{"name":" alpha "}},{"custom":{"name":" beta"}},{"custom":{"name":"gamma "}}]}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "tools.*.custom.name",
|
||||||
|
"mode": "trim_space",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Tools []struct {
|
||||||
|
Custom struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"custom"`
|
||||||
|
} `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &got); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal output JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := lo.Map(got.Tools, func(item struct {
|
||||||
|
Custom struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"custom"`
|
||||||
|
}, _ int) string {
|
||||||
|
return item.Custom.Name
|
||||||
|
})
|
||||||
|
if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) {
|
||||||
|
t.Fatalf("unexpected names after wildcard trim_space: %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideDeleteWildcardEqualsIndexedPaths(t *testing.T) {
|
||||||
|
input := []byte(`{"tools":[{"custom":{"input_examples":["a"],"other":1}},{"custom":{"input_examples":["b"],"other":2}},{"custom":{"input_examples":["c"],"other":3}}]}`)
|
||||||
|
|
||||||
|
wildcardOverride := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "tools.*.custom.input_examples",
|
||||||
|
"mode": "delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
indexedOverride := map[string]interface{}{
|
||||||
|
"operations": lo.Map(lo.Range(3), func(index int, _ int) interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"path": fmt.Sprintf("tools.%d.custom.input_examples", index),
|
||||||
|
"mode": "delete",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
wildcardOut, err := ApplyParamOverride(input, wildcardOverride, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wildcard ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexedOut, err := ApplyParamOverride(input, indexedOverride, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("indexed ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertJSONEqual(t, string(indexedOut), string(wildcardOut))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetWildcardKeepOrigin(t *testing.T) {
|
||||||
|
input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B","enabled":false}},{"custom":{"tag":"C"}}]}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "tools.*.custom.enabled",
|
||||||
|
"mode": "set",
|
||||||
|
"value": true,
|
||||||
|
"keep_origin": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Tools []struct {
|
||||||
|
Custom struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
} `json:"custom"`
|
||||||
|
} `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &got); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal output JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledValues := lo.Map(got.Tools, func(item struct {
|
||||||
|
Custom struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
} `json:"custom"`
|
||||||
|
}, _ int) bool {
|
||||||
|
return item.Custom.Enabled
|
||||||
|
})
|
||||||
|
if !reflect.DeepEqual(enabledValues, []bool{true, false, true}) {
|
||||||
|
t.Fatalf("unexpected enabled values after wildcard keep_origin set: %v", enabledValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideTrimSpaceMultiWildcardPath(t *testing.T) {
|
||||||
|
input := []byte(`{"tools":[{"custom":{"items":[{"name":" alpha "},{"name":" beta "}]}},{"custom":{"items":[{"name":" gamma"}]}}]}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "tools.*.custom.items.*.name",
|
||||||
|
"mode": "trim_space",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Tools []struct {
|
||||||
|
Custom struct {
|
||||||
|
Items []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"custom"`
|
||||||
|
} `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &got); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal output JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := lo.FlatMap(got.Tools, func(tool struct {
|
||||||
|
Custom struct {
|
||||||
|
Items []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"custom"`
|
||||||
|
}, _ int) []string {
|
||||||
|
return lo.Map(tool.Custom.Items, func(item struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}, _ int) string {
|
||||||
|
return item.Name
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) {
|
||||||
|
t.Fatalf("unexpected names after multi wildcard trim_space: %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyParamOverrideSet(t *testing.T) {
|
func TestApplyParamOverrideSet(t *testing.T) {
|
||||||
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
|
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
|
||||||
override := map[string]interface{}{
|
override := map[string]interface{}{
|
||||||
@@ -261,6 +481,42 @@ func TestApplyParamOverrideSet(t *testing.T) {
|
|||||||
assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out))
|
assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetWithDescriptionKeepsCompatibility(t *testing.T) {
|
||||||
|
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
|
||||||
|
overrideWithoutDesc := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "temperature",
|
||||||
|
"mode": "set",
|
||||||
|
"value": 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
overrideWithDesc := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"description": "set temperature for deterministic output",
|
||||||
|
"path": "temperature",
|
||||||
|
"mode": "set",
|
||||||
|
"value": 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
outWithoutDesc, err := ApplyParamOverride(input, overrideWithoutDesc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride without description returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outWithDesc, err := ApplyParamOverride(input, overrideWithDesc, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride with description returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertJSONEqual(t, string(outWithoutDesc), string(outWithDesc))
|
||||||
|
assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(outWithDesc))
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyParamOverrideSetKeepOrigin(t *testing.T) {
|
func TestApplyParamOverrideSetKeepOrigin(t *testing.T) {
|
||||||
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
|
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
|
||||||
override := map[string]interface{}{
|
override := map[string]interface{}{
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ const LEGACY_TEMPLATE = {
|
|||||||
const OPERATION_TEMPLATE = {
|
const OPERATION_TEMPLATE = {
|
||||||
operations: [
|
operations: [
|
||||||
{
|
{
|
||||||
|
description: 'Set default temperature for openai/* models.',
|
||||||
path: 'temperature',
|
path: 'temperature',
|
||||||
mode: 'set',
|
mode: 'set',
|
||||||
value: 0.7,
|
value: 0.7,
|
||||||
@@ -294,8 +295,9 @@ const OPERATION_TEMPLATE = {
|
|||||||
const HEADER_PASSTHROUGH_TEMPLATE = {
|
const HEADER_PASSTHROUGH_TEMPLATE = {
|
||||||
operations: [
|
operations: [
|
||||||
{
|
{
|
||||||
|
description: 'Pass through X-Request-Id header to upstream.',
|
||||||
mode: 'pass_headers',
|
mode: 'pass_headers',
|
||||||
value: ['Authorization'],
|
value: ['X-Request-Id'],
|
||||||
keep_origin: true,
|
keep_origin: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -304,6 +306,8 @@ const HEADER_PASSTHROUGH_TEMPLATE = {
|
|||||||
const GEMINI_IMAGE_4K_TEMPLATE = {
|
const GEMINI_IMAGE_4K_TEMPLATE = {
|
||||||
operations: [
|
operations: [
|
||||||
{
|
{
|
||||||
|
description:
|
||||||
|
'Set imageSize to 4K when model contains gemini/image and ends with 4k.',
|
||||||
mode: 'set',
|
mode: 'set',
|
||||||
path: 'generationConfig.imageConfig.imageSize',
|
path: 'generationConfig.imageConfig.imageSize',
|
||||||
value: '4K',
|
value: '4K',
|
||||||
@@ -311,7 +315,17 @@ const GEMINI_IMAGE_4K_TEMPLATE = {
|
|||||||
{
|
{
|
||||||
path: 'original_model',
|
path: 'original_model',
|
||||||
mode: 'contains',
|
mode: 'contains',
|
||||||
value: 'gemini-3-pro-image-preview',
|
value: 'gemini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'original_model',
|
||||||
|
mode: 'contains',
|
||||||
|
value: 'image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'original_model',
|
||||||
|
mode: 'suffix',
|
||||||
|
value: '4k',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
logic: 'AND',
|
logic: 'AND',
|
||||||
@@ -319,11 +333,13 @@ const GEMINI_IMAGE_4K_TEMPLATE = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = {
|
const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = {
|
||||||
operations: [
|
operations: [
|
||||||
{
|
{
|
||||||
|
description: 'Normalize anthropic-beta header tokens for Bedrock compatibility.',
|
||||||
mode: 'set_header',
|
mode: 'set_header',
|
||||||
path: 'anthropic-beta',
|
path: 'anthropic-beta',
|
||||||
|
// https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json
|
||||||
value: {
|
value: {
|
||||||
'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19',
|
'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19',
|
||||||
bash_20241022: null,
|
bash_20241022: null,
|
||||||
@@ -355,6 +371,11 @@ const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = {
|
|||||||
'web-search-2025-03-05': null,
|
'web-search-2025-03-05': null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: 'Remove all tools[*].custom.input_examples before upstream relay.',
|
||||||
|
mode: 'delete',
|
||||||
|
path: 'tools.*.custom.input_examples',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -378,7 +399,7 @@ const TEMPLATE_PRESET_CONFIG = {
|
|||||||
},
|
},
|
||||||
pass_headers_auth: {
|
pass_headers_auth: {
|
||||||
group: 'scenario',
|
group: 'scenario',
|
||||||
label: '请求头透传(Authorization)',
|
label: '请求头透传(X-Request-Id)',
|
||||||
kind: 'operations',
|
kind: 'operations',
|
||||||
payload: HEADER_PASSTHROUGH_TEMPLATE,
|
payload: HEADER_PASSTHROUGH_TEMPLATE,
|
||||||
},
|
},
|
||||||
@@ -402,9 +423,9 @@ const TEMPLATE_PRESET_CONFIG = {
|
|||||||
},
|
},
|
||||||
aws_bedrock_anthropic_beta_override: {
|
aws_bedrock_anthropic_beta_override: {
|
||||||
group: 'scenario',
|
group: 'scenario',
|
||||||
label: 'AWS Bedrock anthropic-beta覆盖',
|
label: 'AWS Bedrock Claude 兼容模板',
|
||||||
kind: 'operations',
|
kind: 'operations',
|
||||||
payload: AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE,
|
payload: AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -764,6 +785,7 @@ const createDefaultCondition = () => normalizeCondition({});
|
|||||||
|
|
||||||
const normalizeOperation = (operation = {}) => ({
|
const normalizeOperation = (operation = {}) => ({
|
||||||
id: nextLocalId(),
|
id: nextLocalId(),
|
||||||
|
description: typeof operation.description === 'string' ? operation.description : '',
|
||||||
path: typeof operation.path === 'string' ? operation.path : '',
|
path: typeof operation.path === 'string' ? operation.path : '',
|
||||||
mode: OPERATION_MODE_VALUES.has(operation.mode) ? operation.mode : 'set',
|
mode: OPERATION_MODE_VALUES.has(operation.mode) ? operation.mode : 'set',
|
||||||
value_text: toValueText(operation.value),
|
value_text: toValueText(operation.value),
|
||||||
@@ -1086,6 +1108,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
if (!keyword) return operations;
|
if (!keyword) return operations;
|
||||||
return operations.filter((operation) => {
|
return operations.filter((operation) => {
|
||||||
const searchableText = [
|
const searchableText = [
|
||||||
|
operation.description,
|
||||||
operation.mode,
|
operation.mode,
|
||||||
operation.path,
|
operation.path,
|
||||||
operation.from,
|
operation.from,
|
||||||
@@ -1151,10 +1174,14 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
const payloadOps = filteredOps.map((operation) => {
|
const payloadOps = filteredOps.map((operation) => {
|
||||||
const mode = operation.mode || 'set';
|
const mode = operation.mode || 'set';
|
||||||
const meta = MODE_META[mode] || MODE_META.set;
|
const meta = MODE_META[mode] || MODE_META.set;
|
||||||
|
const descriptionValue = String(operation.description || '').trim();
|
||||||
const pathValue = operation.path.trim();
|
const pathValue = operation.path.trim();
|
||||||
const fromValue = operation.from.trim();
|
const fromValue = operation.from.trim();
|
||||||
const toValue = operation.to.trim();
|
const toValue = operation.to.trim();
|
||||||
const payload = { mode };
|
const payload = { mode };
|
||||||
|
if (descriptionValue) {
|
||||||
|
payload.description = descriptionValue;
|
||||||
|
}
|
||||||
if (meta.path) {
|
if (meta.path) {
|
||||||
payload.path = pathValue;
|
payload.path = pathValue;
|
||||||
}
|
}
|
||||||
@@ -1563,6 +1590,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
if (index < 0) return prev;
|
if (index < 0) return prev;
|
||||||
const source = prev[index];
|
const source = prev[index];
|
||||||
const cloned = normalizeOperation({
|
const cloned = normalizeOperation({
|
||||||
|
description: source.description,
|
||||||
path: source.path,
|
path: source.path,
|
||||||
mode: source.mode,
|
mode: source.mode,
|
||||||
value: parseLooseValue(source.value_text),
|
value: parseLooseValue(source.value_text),
|
||||||
@@ -1891,7 +1919,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
|
|
||||||
<Input
|
<Input
|
||||||
value={operationSearch}
|
value={operationSearch}
|
||||||
placeholder={t('搜索规则(类型 / 路径 / 来源 / 目标)')}
|
placeholder={t('搜索规则(描述 / 类型 / 路径 / 来源 / 目标)')}
|
||||||
onChange={(nextValue) =>
|
onChange={(nextValue) =>
|
||||||
setOperationSearch(nextValue || '')
|
setOperationSearch(nextValue || '')
|
||||||
}
|
}
|
||||||
@@ -1958,6 +1986,23 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
>
|
>
|
||||||
{getOperationSummary(operation, index)}
|
{getOperationSummary(operation, index)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{String(operation.description || '').trim() ? (
|
||||||
|
<Text
|
||||||
|
type='tertiary'
|
||||||
|
size='small'
|
||||||
|
className='block mt-1'
|
||||||
|
style={{
|
||||||
|
lineHeight: 1.5,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{operation.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Tag size='small' color='grey'>
|
<Tag size='small' color='grey'>
|
||||||
{(operation.conditions || []).length}
|
{(operation.conditions || []).length}
|
||||||
@@ -2035,6 +2080,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
type='danger'
|
type='danger'
|
||||||
theme='borderless'
|
theme='borderless'
|
||||||
icon={<IconDelete />}
|
icon={<IconDelete />}
|
||||||
|
aria-label={t('删除规则')}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
removeOperation(selectedOperation.id)
|
removeOperation(selectedOperation.id)
|
||||||
}
|
}
|
||||||
@@ -2085,6 +2131,25 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
|||||||
>
|
>
|
||||||
{MODE_DESCRIPTIONS[mode] || ''}
|
{MODE_DESCRIPTIONS[mode] || ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
<div className='mt-2'>
|
||||||
|
<Text type='tertiary' size='small'>
|
||||||
|
{t('规则描述(可选)')}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={selectedOperation.description || ''}
|
||||||
|
placeholder={t('例如:清理工具参数,避免上游校验错误')}
|
||||||
|
onChange={(nextValue) =>
|
||||||
|
updateOperation(selectedOperation.id, {
|
||||||
|
description: nextValue || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
maxLength={180}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<Text type='tertiary' size='small' className='mt-1 block'>
|
||||||
|
{`${String(selectedOperation.description || '').length}/180`}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
{meta.value ? (
|
{meta.value ? (
|
||||||
mode === 'return_error' && returnErrorDraft ? (
|
mode === 'return_error' && returnErrorDraft ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user