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 48dbb271f..5293fae76 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,17 +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 规则:',
+ '{',
+ ' "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';
@@ -258,11 +270,6 @@ const getModeValuePlaceholder = (mode) => {
return '0.7';
};
-const getModeValueHelp = (mode) => {
- if (mode !== 'set_header') return '';
- return '字符串:整条请求头直接覆盖。JSON 映射:按逗号分隔 token 逐项处理,null 表示删除,string/array 表示替换,* 表示兜底规则。';
-};
-
const SYNC_TARGET_TYPE_OPTIONS = [
{ label: '请求体字段', value: 'json' },
{ label: '请求头字段', value: 'header' },
@@ -1075,6 +1082,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('');
@@ -1101,6 +1109,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
setTemplateGroupKey('basic');
setTemplatePresetKey('operations_default');
}
+ setHeaderValueExampleVisible(false);
setFieldGuideVisible(false);
setFieldGuideTarget('path');
setFieldGuideKeyword('');
@@ -2823,15 +2832,35 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
{t(getModeValueLabel(mode))}
{mode === 'set_header' ? (
-
+
+
+
+
) : null}
+ {mode === 'set_header' ? (
+
+ {t('纯字符串会直接覆盖整条请求头,或者点击“查看 JSON 示例”按 token 规则处理。')}
+
+ ) : null}