From 3286f3da4dc6950764613165c79ec981b5b90fdb Mon Sep 17 00:00:00 2001 From: Seefs Date: Fri, 27 Feb 2026 19:47:32 +0800 Subject: [PATCH] feat: support token-map rewrite for comma-separated headers and add bedrock anthropic-beta preset --- relay/common/override.go | 124 +++++++++++++++++- relay/common/override_test.go | 102 ++++++++++++++ .../modals/ParamOverrideEditorModal.jsx | 72 +++++++++- 3 files changed, 288 insertions(+), 10 deletions(-) diff --git a/relay/common/override.go b/relay/common/override.go index 95af8cfae..59e15176b 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -690,13 +690,6 @@ func setHeaderOverrideInContext(context map[string]interface{}, headerName strin if headerName == "" { return fmt.Errorf("header name is required") } - if value == nil { - return fmt.Errorf("header value is required") - } - headerValue := strings.TrimSpace(fmt.Sprintf("%v", value)) - if headerValue == "" { - return fmt.Errorf("header value is required") - } rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride) if keepOrigin { @@ -707,10 +700,127 @@ func setHeaderOverrideInContext(context map[string]interface{}, headerName strin } } } + + headerValue, hasValue, err := resolveHeaderOverrideValue(context, headerName, value) + if err != nil { + return err + } + if !hasValue { + delete(rawHeaders, headerName) + return nil + } + rawHeaders[headerName] = headerValue return nil } +func resolveHeaderOverrideValue(context map[string]interface{}, headerName string, value interface{}) (string, bool, error) { + if value == nil { + return "", false, fmt.Errorf("header value is required") + } + + if mapping, ok := value.(map[string]interface{}); ok { + return resolveHeaderOverrideValueByMapping(context, headerName, mapping) + } + if mapping, ok := value.(map[string]string); ok { + converted := make(map[string]interface{}, len(mapping)) + for key, item := range mapping { + converted[key] = item + } + return resolveHeaderOverrideValueByMapping(context, headerName, converted) + } + + headerValue := strings.TrimSpace(fmt.Sprintf("%v", value)) + if headerValue == "" { + return "", false, nil + } + return headerValue, true, nil +} + +func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerName string, mapping map[string]interface{}) (string, bool, error) { + if len(mapping) == 0 { + return "", false, fmt.Errorf("header value mapping cannot be empty") + } + + sourceValue, exists := getHeaderValueFromContext(context, headerName) + if !exists { + return "", false, nil + } + sourceTokens := splitHeaderListValue(sourceValue) + if len(sourceTokens) == 0 { + return "", false, nil + } + + wildcardValue, hasWildcard := mapping["*"] + resultTokens := make([]string, 0, len(sourceTokens)) + for _, token := range sourceTokens { + replacementRaw, hasReplacement := mapping[token] + if !hasReplacement && hasWildcard { + replacementRaw = wildcardValue + hasReplacement = true + } + if !hasReplacement { + resultTokens = append(resultTokens, token) + continue + } + replacementTokens, err := parseHeaderReplacementTokens(replacementRaw) + if err != nil { + return "", false, err + } + resultTokens = append(resultTokens, replacementTokens...) + } + + resultTokens = lo.Uniq(resultTokens) + if len(resultTokens) == 0 { + return "", false, nil + } + return strings.Join(resultTokens, ","), true, nil +} + +func parseHeaderReplacementTokens(value interface{}) ([]string, error) { + switch raw := value.(type) { + case nil: + return nil, nil + case string: + return splitHeaderListValue(raw), nil + case []string: + tokens := make([]string, 0, len(raw)) + for _, item := range raw { + tokens = append(tokens, splitHeaderListValue(item)...) + } + return lo.Uniq(tokens), nil + case []interface{}: + tokens := make([]string, 0, len(raw)) + for _, item := range raw { + itemTokens, err := parseHeaderReplacementTokens(item) + if err != nil { + return nil, err + } + tokens = append(tokens, itemTokens...) + } + return lo.Uniq(tokens), nil + case map[string]interface{}, map[string]string: + return nil, fmt.Errorf("header replacement value must be string, array or null") + default: + token := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if token == "" { + return nil, nil + } + return []string{token}, nil + } +} + +func splitHeaderListValue(raw string) []string { + items := strings.Split(raw, ",") + return lo.FilterMap(items, func(item string, _ int) (string, bool) { + token := strings.TrimSpace(item) + if token == "" { + return "", false + } + return token, true + }) +} + func copyHeaderInContext(context map[string]interface{}, fromHeader, toHeader string, keepOrigin bool) error { fromHeader = normalizeHeaderContextKey(fromHeader) toHeader = normalizeHeaderContextKey(toHeader) diff --git a/relay/common/override_test.go b/relay/common/override_test.go index 7a27ca407..5f49d95ae 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -1287,6 +1287,74 @@ func TestApplyParamOverrideSetHeaderKeepOrigin(t *testing.T) { } } +func TestApplyParamOverrideSetHeaderMapRewritesCommaSeparatedHeader(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{}{ + "advanced-tool-use-2025-11-20": nil, + "computer-use-2025-01-24": "computer-use-2025-01-24", + }, + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20, computer-use-2025-01-24", + }, + } + + _, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + 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" { + t.Fatalf("expected anthropic-beta to keep only mapped value, got: %v", headers["anthropic-beta"]) + } +} + +func TestApplyParamOverrideSetHeaderMapDeleteWholeHeaderWhenAllTokensCleared(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{}{ + "advanced-tool-use-2025-11-20": nil, + "computer-use-2025-01-24": nil, + }, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20,computer-use-2025-01-24", + }, + } + + _, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + 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 all mapped values are null") + } +} + func TestApplyParamOverrideConditionsObjectShorthand(t *testing.T) { input := []byte(`{"temperature":0.7}`) override := map[string]interface{}{ @@ -1400,6 +1468,40 @@ func TestApplyParamOverrideWithRelayInfoMoveAndCopyHeaders(t *testing.T) { } } +func TestApplyParamOverrideWithRelayInfoSetHeaderMapRewritesAnthropicBeta(t *testing.T) { + info := &RelayInfo{ + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "advanced-tool-use-2025-11-20": nil, + "computer-use-2025-01-24": "computer-use-2025-01-24", + }, + }, + }, + }, + HeadersOverride: map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20, computer-use-2025-01-24", + }, + }, + } + + _, err := ApplyParamOverrideWithRelayInfo([]byte(`{"temperature":0.7}`), info) + if err != nil { + t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err) + } + + if !info.UseRuntimeHeadersOverride { + t.Fatalf("expected runtime header override to be enabled") + } + if info.RuntimeHeadersOverride["anthropic-beta"] != "computer-use-2025-01-24" { + t.Fatalf("expected anthropic-beta to be rewritten, got: %v", info.RuntimeHeadersOverride["anthropic-beta"]) + } +} + func TestGetEffectiveHeaderOverrideUsesRuntimeOverrideAsFinalResult(t *testing.T) { info := &RelayInfo{ UseRuntimeHeadersOverride: true, diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index 8dfd0d51e..56cf5bf16 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: '设置运行期请求头', + set_header: '设置运行期请求头(支持整值覆盖,或用 JSON 映射按逗号 token 替换/删除)', delete_header: '删除运行期请求头', copy_header: '复制请求头', move_header: '移动请求头', @@ -214,7 +214,7 @@ const getModeToPlaceholder = (mode) => { }; const getModeValueLabel = (mode) => { - if (mode === 'set_header') return '请求头值'; + if (mode === 'set_header') return '请求头值(支持字符串或 JSON 映射)'; if (mode === 'pass_headers') return '透传请求头(支持逗号分隔或 JSON 数组)'; if ( mode === 'trim_prefix' || @@ -231,7 +231,18 @@ const getModeValueLabel = (mode) => { }; const getModeValuePlaceholder = (mode) => { - if (mode === 'set_header') return 'Bearer sk-xxx'; + 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"}', + ].join('\n'); + } if (mode === 'pass_headers') return 'Authorization, X-Request-Id'; if ( mode === 'trim_prefix' || @@ -247,6 +258,11 @@ 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' }, @@ -303,6 +319,45 @@ const GEMINI_IMAGE_4K_TEMPLATE = { ], }; +const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = { + operations: [ + { + mode: 'set_header', + path: 'anthropic-beta', + value: { + 'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19', + bash_20241022: null, + bash_20250124: null, + 'code-execution-2025-08-25': null, + 'compact-2026-01-12': 'compact-2026-01-12', + 'computer-use-2025-01-24': 'computer-use-2025-01-24', + 'computer-use-2025-11-24': 'computer-use-2025-11-24', + 'context-1m-2025-08-07': 'context-1m-2025-08-07', + 'context-management-2025-06-27': 'context-management-2025-06-27', + 'effort-2025-11-24': null, + 'fast-mode-2026-02-01': null, + 'files-api-2025-04-14': null, + 'fine-grained-tool-streaming-2025-05-14': null, + 'interleaved-thinking-2025-05-14': 'interleaved-thinking-2025-05-14', + 'mcp-client-2025-11-20': null, + 'mcp-client-2025-04-04': null, + 'mcp-servers-2025-12-04': null, + 'output-128k-2025-02-19': null, + 'structured-output-2024-03-01': null, + 'prompt-caching-scope-2026-01-05': null, + 'skills-2025-10-02': null, + 'structured-outputs-2025-11-13': null, + text_editor_20241022: null, + text_editor_20250124: null, + 'token-efficient-tools-2025-02-19': null, + 'tool-search-tool-2025-10-19': 'tool-search-tool-2025-10-19', + 'web-fetch-2025-09-10': null, + 'web-search-2025-03-05': null, + }, + }, + ], +}; + const TEMPLATE_GROUP_OPTIONS = [ { label: '基础模板', value: 'basic' }, { label: '场景模板', value: 'scenario' }, @@ -345,6 +400,12 @@ const TEMPLATE_PRESET_CONFIG = { kind: 'operations', payload: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE, }, + aws_bedrock_anthropic_beta_override: { + group: 'scenario', + label: 'AWS Bedrock anthropic-beta覆盖', + kind: 'operations', + payload: AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE, + }, }; const FIELD_GUIDE_TARGET_OPTIONS = [ @@ -2560,6 +2621,11 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { }) } /> + {getModeValueHelp(mode) ? ( + + {t(getModeValueHelp(mode))} + + ) : null} ) ) : null}