From 16e4ce52e3ef6afb1da0824623e8bc49eb8b6818 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 5 Mar 2026 16:39:34 +0800 Subject: [PATCH] feat: add wildcard path support and improve param override templates/editor --- relay/common/override.go | 201 ++++++++++++-- relay/common/override_test.go | 256 ++++++++++++++++++ .../modals/ParamOverrideEditorModal.jsx | 79 +++++- 3 files changed, 512 insertions(+), 24 deletions(-) diff --git a/relay/common/override.go b/relay/common/override.go index e0761ab63..fc43abd89 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" + "sort" "strconv" "strings" @@ -487,15 +488,35 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte } // 处理路径中的负数索引 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 { case "delete": - result, err = sjson.Delete(result, opPath) - case "set": - if op.KeepOrigin && gjson.Get(result, opPath).Exists() { - continue + for _, path := range opPaths { + result, err = deleteValue(result, path) + if err != nil { + 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": opFrom := processNegativeIndex(result, op.From) opTo := processNegativeIndex(result, op.To) @@ -508,27 +529,82 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte opTo := processNegativeIndex(result, op.To) result, err = copyValue(result, opFrom, opTo) 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": - 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": - 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": - 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": - 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": - 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": - 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": - 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": - result, err = transformStringValue(result, opPath, strings.ToUpper) + for _, path := range opPaths { + result, err = transformStringValue(result, path, strings.ToUpper) + if err != nil { + break + } + } 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": - 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": returnErr, parseErr := parseParamOverrideReturnError(op.Value) if parseErr != nil { @@ -536,7 +612,12 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte } return "", returnErr 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": err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin) if err == nil { @@ -1174,6 +1255,92 @@ func copyValue(jsonStr, fromPath, toPath string) (string, error) { 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) { current := gjson.Get(jsonStr, path) switch { diff --git a/relay/common/override_test.go b/relay/common/override_test.go index dbdcd4096..b450af3bb 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -2,6 +2,7 @@ package common import ( "encoding/json" + "fmt" "reflect" "testing" @@ -9,6 +10,7 @@ import ( "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/samber/lo" ) 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) { input := []byte(`{"model":"gpt-4","temperature":0.7}`) override := map[string]interface{}{ @@ -261,6 +481,42 @@ func TestApplyParamOverrideSet(t *testing.T) { 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) { input := []byte(`{"model":"gpt-4","temperature":0.7}`) override := map[string]interface{}{ diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index 8aa46a3f6..4564557e8 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx @@ -276,6 +276,7 @@ const LEGACY_TEMPLATE = { const OPERATION_TEMPLATE = { operations: [ { + description: 'Set default temperature for openai/* models.', path: 'temperature', mode: 'set', value: 0.7, @@ -294,8 +295,9 @@ const OPERATION_TEMPLATE = { const HEADER_PASSTHROUGH_TEMPLATE = { operations: [ { + description: 'Pass through X-Request-Id header to upstream.', mode: 'pass_headers', - value: ['Authorization'], + value: ['X-Request-Id'], keep_origin: true, }, ], @@ -304,6 +306,8 @@ const HEADER_PASSTHROUGH_TEMPLATE = { const GEMINI_IMAGE_4K_TEMPLATE = { operations: [ { + description: + 'Set imageSize to 4K when model contains gemini/image and ends with 4k.', mode: 'set', path: 'generationConfig.imageConfig.imageSize', value: '4K', @@ -311,7 +315,17 @@ const GEMINI_IMAGE_4K_TEMPLATE = { { path: 'original_model', 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', @@ -319,11 +333,13 @@ const GEMINI_IMAGE_4K_TEMPLATE = { ], }; -const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = { +const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = { operations: [ { + description: 'Normalize anthropic-beta header tokens for Bedrock compatibility.', mode: 'set_header', path: 'anthropic-beta', + // https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json value: { 'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19', bash_20241022: null, @@ -355,6 +371,11 @@ const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = { '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: { group: 'scenario', - label: '请求头透传(Authorization)', + label: '请求头透传(X-Request-Id)', kind: 'operations', payload: HEADER_PASSTHROUGH_TEMPLATE, }, @@ -402,9 +423,9 @@ const TEMPLATE_PRESET_CONFIG = { }, aws_bedrock_anthropic_beta_override: { group: 'scenario', - label: 'AWS Bedrock anthropic-beta覆盖', + label: 'AWS Bedrock Claude 兼容模板', 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 = {}) => ({ id: nextLocalId(), + description: typeof operation.description === 'string' ? operation.description : '', path: typeof operation.path === 'string' ? operation.path : '', mode: OPERATION_MODE_VALUES.has(operation.mode) ? operation.mode : 'set', value_text: toValueText(operation.value), @@ -1086,6 +1108,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { if (!keyword) return operations; return operations.filter((operation) => { const searchableText = [ + operation.description, operation.mode, operation.path, operation.from, @@ -1151,10 +1174,14 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { const payloadOps = filteredOps.map((operation) => { const mode = operation.mode || 'set'; const meta = MODE_META[mode] || MODE_META.set; + const descriptionValue = String(operation.description || '').trim(); const pathValue = operation.path.trim(); const fromValue = operation.from.trim(); const toValue = operation.to.trim(); const payload = { mode }; + if (descriptionValue) { + payload.description = descriptionValue; + } if (meta.path) { payload.path = pathValue; } @@ -1563,6 +1590,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { if (index < 0) return prev; const source = prev[index]; const cloned = normalizeOperation({ + description: source.description, path: source.path, mode: source.mode, value: parseLooseValue(source.value_text), @@ -1891,7 +1919,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { setOperationSearch(nextValue || '') } @@ -1958,6 +1986,23 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { > {getOperationSummary(operation, index)} + {String(operation.description || '').trim() ? ( + + {operation.description} + + ) : null} {(operation.conditions || []).length} @@ -2035,6 +2080,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { type='danger' theme='borderless' icon={} + aria-label={t('删除规则')} onClick={() => removeOperation(selectedOperation.id) } @@ -2085,6 +2131,25 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { > {MODE_DESCRIPTIONS[mode] || ''} +
+ + {t('规则描述(可选)')} + + + updateOperation(selectedOperation.id, { + description: nextValue || '', + }) + } + maxLength={180} + showClear + /> + + {`${String(selectedOperation.description || '').length}/180`} + +
{meta.value ? ( mode === 'return_error' && returnErrorDraft ? (