From 6ff5a5dc99244bee4590a6d1d3071afa75d2011b Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 9 Mar 2026 00:12:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat=EF=BC=9Asupport=20$keep=5Fonly=5Fdecla?= =?UTF-8?q?red=20and=20deduped=20$append=20for=20header=20token=20override?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/common/override.go | 43 ++++-- relay/common/override_test.go | 135 ++++++++++++++++++ .../modals/ParamOverrideEditorModal.jsx | 10 +- 3 files changed, 178 insertions(+), 10 deletions(-) diff --git a/relay/common/override.go b/relay/common/override.go index fc43abd89..8bfdcd743 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -847,24 +847,30 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN return "", false, fmt.Errorf("header value mapping cannot be empty") } - sourceValue, exists := getHeaderValueFromContext(context, headerName) - if !exists { - return "", false, nil + appendTokens, err := parseHeaderAppendTokens(mapping) + if err != nil { + return "", false, err } - sourceTokens := splitHeaderListValue(sourceValue) - if len(sourceTokens) == 0 { - return "", false, nil + keepOnlyDeclared := parseHeaderKeepOnlyDeclared(mapping) + + sourceValue, exists := getHeaderValueFromContext(context, headerName) + sourceTokens := make([]string, 0) + if exists { + sourceTokens = splitHeaderListValue(sourceValue) } wildcardValue, hasWildcard := mapping["*"] - resultTokens := make([]string, 0, len(sourceTokens)) + resultTokens := make([]string, 0, len(sourceTokens)+len(appendTokens)) for _, token := range sourceTokens { replacementRaw, hasReplacement := mapping[token] - if !hasReplacement && hasWildcard { + if !hasReplacement && hasWildcard && !keepOnlyDeclared { replacementRaw = wildcardValue hasReplacement = true } if !hasReplacement { + if keepOnlyDeclared { + continue + } resultTokens = append(resultTokens, token) continue } @@ -875,6 +881,7 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN resultTokens = append(resultTokens, replacementTokens...) } + resultTokens = append(resultTokens, appendTokens...) resultTokens = lo.Uniq(resultTokens) if len(resultTokens) == 0 { return "", false, nil @@ -882,6 +889,26 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN return strings.Join(resultTokens, ","), true, nil } +func parseHeaderAppendTokens(mapping map[string]interface{}) ([]string, error) { + appendRaw, ok := mapping["$append"] + if !ok { + return nil, nil + } + return parseHeaderReplacementTokens(appendRaw) +} + +func parseHeaderKeepOnlyDeclared(mapping map[string]interface{}) bool { + keepOnlyDeclaredRaw, ok := mapping["$keep_only_declared"] + if !ok { + return false + } + keepOnlyDeclared, ok := keepOnlyDeclaredRaw.(bool) + if !ok { + return false + } + return keepOnlyDeclared +} + func parseHeaderReplacementTokens(value interface{}) ([]string, error) { switch raw := value.(type) { case nil: diff --git a/relay/common/override_test.go b/relay/common/override_test.go index b450af3bb..c41be219c 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -1653,6 +1653,141 @@ func TestApplyParamOverrideSetHeaderMapDeleteWholeHeaderWhenAllTokensCleared(t * } } +func TestApplyParamOverrideSetHeaderMapAppendsTokens(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "$append": []interface{}{"context-1m-2025-08-07", "computer-use-2025-01-24"}, + }, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "anthropic-beta": "computer-use-2025-01-24", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["anthropic-beta"] != "computer-use-2025-01-24,context-1m-2025-08-07" { + t.Fatalf("expected anthropic-beta to append new token without duplicates, got: %v", headers["anthropic-beta"]) + } +} + +func TestApplyParamOverrideSetHeaderMapAppendsTokensWhenHeaderMissing(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "$append": []interface{}{"context-1m-2025-08-07", "computer-use-2025-01-24"}, + }, + }, + }, + } + + ctx := map[string]interface{}{} + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["anthropic-beta"] != "context-1m-2025-08-07,computer-use-2025-01-24" { + t.Fatalf("expected anthropic-beta to be created from appended tokens, got: %v", headers["anthropic-beta"]) + } +} + +func TestApplyParamOverrideSetHeaderMapKeepOnlyDeclaredDropsUndeclaredTokens(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "computer-use-2025-01-24": "computer-use-2025-01-24", + "$append": []interface{}{"context-1m-2025-08-07"}, + "$keep_only_declared": true, + }, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20,computer-use-2025-01-24", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["anthropic-beta"] != "computer-use-2025-01-24,context-1m-2025-08-07" { + t.Fatalf("expected anthropic-beta to keep only declared tokens, got: %v", headers["anthropic-beta"]) + } +} + +func TestApplyParamOverrideSetHeaderMapKeepOnlyDeclaredDeletesHeaderWhenNothingDeclaredMatches(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "computer-use-2025-01-24": "computer-use-2025-01-24", + "$keep_only_declared": true, + }, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if _, exists := headers["anthropic-beta"]; exists { + t.Fatalf("expected anthropic-beta to be deleted when no declared tokens remain, got: %v", headers["anthropic-beta"]) + } +} + func TestApplyParamOverrideConditionsObjectShorthand(t *testing.T) { input := []byte(`{"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 67cbe51c7..cff85a139 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx @@ -163,7 +163,7 @@ const MODE_DESCRIPTIONS = { prune_objects: '按条件清理对象中的子项', pass_headers: '把指定请求头透传到上游请求', sync_fields: '在一个字段有值、另一个缺失时自动补齐', - set_header: '设置运行期请求头(支持整值覆盖,或用 JSON 映射按逗号 token 替换/删除)', + set_header: '设置运行期请求头(支持整值覆盖,或用 JSON 映射按逗号 token 替换/删除/追加/白名单保留)', delete_header: '删除运行期请求头', copy_header: '复制请求头', move_header: '移动请求头', @@ -241,6 +241,12 @@ const getModeValuePlaceholder = (mode) => { '', 'JSON map wildcard:', '{"*": null, "computer-use-2025-11-24": "computer-use-2025-11-24"}', + '', + 'JSON append example:', + '{"$append": ["context-1m-2025-08-07"]}', + '', + 'JSON strict keep example:', + '{"computer-use-2025-01-24": "computer-use-2025-01-24", "$append": ["context-1m-2025-08-07"], "$keep_only_declared": true}', ].join('\n'); } if (mode === 'pass_headers') return 'Authorization, X-Request-Id'; @@ -260,7 +266,7 @@ const getModeValuePlaceholder = (mode) => { const getModeValueHelp = (mode) => { if (mode !== 'set_header') return ''; - return '字符串:整条请求头直接覆盖。JSON 映射:按逗号分隔 token 逐项处理,null 表示删除,string/array 表示替换,* 表示兜底规则。'; + return '字符串:整条请求头直接覆盖。JSON 映射:按逗号分隔 token 逐项处理,null 表示删除,string/array 表示替换,* 表示兜底规则,$append 可在末尾追加新 token,$keep_only_declared=true 时会丢弃未声明 token。'; }; const SYNC_TARGET_TYPE_OPTIONS = [ From c3b9ae5f3b5a4fb43207a259d73e79a4a687eae8 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 10 Mar 2026 01:59:34 +0800 Subject: [PATCH 2/2] refactor: optimize header override copy and JSON example dialog --- .../modals/ParamOverrideEditorModal.jsx | 99 +++++++++++++------ 1 file changed, 69 insertions(+), 30 deletions(-) diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index cff85a139..05c5d057c 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx @@ -163,7 +163,7 @@ const MODE_DESCRIPTIONS = { prune_objects: '按条件清理对象中的子项', pass_headers: '把指定请求头透传到上游请求', sync_fields: '在一个字段有值、另一个缺失时自动补齐', - set_header: '设置运行期请求头(支持整值覆盖,或用 JSON 映射按逗号 token 替换/删除/追加/白名单保留)', + set_header: '设置运行期请求头:可直接覆盖整条值,也可对逗号分隔的 token 做删除、替换、追加或白名单保留', delete_header: '删除运行期请求头', copy_header: '复制请求头', move_header: '移动请求头', @@ -230,23 +230,29 @@ const getModeValueLabel = (mode) => { return '值(支持 JSON 或普通文本)'; }; +const HEADER_VALUE_JSONC_EXAMPLE = `{ + // 置空:删除 Bedrock 不支持的 beta特性 + "files-api-2025-04-14": null, + + // 替换:把旧特性改成兼容特性 + "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19", + + // 追加:在末尾补一个需要的特性 + "$append": ["context-1m-2025-08-07"] +}`; + const getModeValuePlaceholder = (mode) => { if (mode === 'set_header') { return [ - 'String example:', + '纯字符串(整条覆盖):', 'Bearer sk-xxx', '', - 'JSON map example:', - '{"advanced-tool-use-2025-11-20": null, "computer-use-2025-01-24": "computer-use-2025-01-24"}', - '', - 'JSON map wildcard:', - '{"*": null, "computer-use-2025-11-24": "computer-use-2025-11-24"}', - '', - 'JSON append example:', - '{"$append": ["context-1m-2025-08-07"]}', - '', - 'JSON strict keep example:', - '{"computer-use-2025-01-24": "computer-use-2025-01-24", "$append": ["context-1m-2025-08-07"], "$keep_only_declared": true}', + '或使用 JSON 规则:', + '{', + ' "files-api-2025-04-14": null,', + ' "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",', + ' "$append": ["context-1m-2025-08-07"]', + '}', ].join('\n'); } if (mode === 'pass_headers') return 'Authorization, X-Request-Id'; @@ -264,11 +270,6 @@ const getModeValuePlaceholder = (mode) => { return '0.7'; }; -const getModeValueHelp = (mode) => { - if (mode !== 'set_header') return ''; - return '字符串:整条请求头直接覆盖。JSON 映射:按逗号分隔 token 逐项处理,null 表示删除,string/array 表示替换,* 表示兜底规则,$append 可在末尾追加新 token,$keep_only_declared=true 时会丢弃未声明 token。'; -}; - const SYNC_TARGET_TYPE_OPTIONS = [ { label: '请求体字段', value: 'json' }, { label: '请求头字段', value: 'header' }, @@ -1080,6 +1081,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { const [dragOverPosition, setDragOverPosition] = useState('before'); const [templateGroupKey, setTemplateGroupKey] = useState('basic'); const [templatePresetKey, setTemplatePresetKey] = useState('operations_default'); + const [headerValueExampleVisible, setHeaderValueExampleVisible] = useState(false); const [fieldGuideVisible, setFieldGuideVisible] = useState(false); const [fieldGuideTarget, setFieldGuideTarget] = useState('path'); const [fieldGuideKeyword, setFieldGuideKeyword] = useState(''); @@ -1106,6 +1108,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { setTemplateGroupKey('basic'); setTemplatePresetKey('operations_default'); } + setHeaderValueExampleVisible(false); setFieldGuideVisible(false); setFieldGuideTarget('path'); setFieldGuideKeyword(''); @@ -2828,15 +2831,35 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { {t(getModeValueLabel(mode))} {mode === 'set_header' ? ( - + + + + ) : null} + {mode === 'set_header' ? ( + + {t('纯字符串会直接覆盖整条请求头,或者点击“查看 JSON 示例”按 token 规则处理。')} + + ) : null}