Prechádzať zdrojové kódy

feat: support token-map rewrite for comma-separated headers and add bedrock anthropic-beta preset

Seefs 1 týždeň pred
rodič
commit
3286f3da4d

+ 117 - 7
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)

+ 102 - 0
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,

+ 69 - 3
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) ? (
+                                    <Text type='tertiary' size='small'>
+                                      {t(getModeValueHelp(mode))}
+                                    </Text>
+                                  ) : null}
                                 </div>
                               )
                             ) : null}