Quellcode durchsuchen

feat:add CLI param-override templates with visual editor and apply on first rule match

Seefs vor 1 Woche
Ursprung
Commit
0519446571

+ 7 - 2
middleware/distributor.go

@@ -348,8 +348,13 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
 	common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
 	common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
 	common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
-	common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride())
-	common.SetContextKey(c, constant.ContextKeyChannelHeaderOverride, channel.GetHeaderOverride())
+	paramOverride := channel.GetParamOverride()
+	headerOverride := channel.GetHeaderOverride()
+	if mergedParam, applied := service.ApplyChannelAffinityOverrideTemplate(c, paramOverride); applied {
+		paramOverride = mergedParam
+	}
+	common.SetContextKey(c, constant.ContextKeyChannelParamOverride, paramOverride)
+	common.SetContextKey(c, constant.ContextKeyChannelHeaderOverride, headerOverride)
 	if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
 		common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
 	}

+ 80 - 0
service/channel_affinity.go

@@ -45,6 +45,7 @@ type channelAffinityMeta struct {
 	TTLSeconds     int
 	RuleName       string
 	SkipRetry      bool
+	ParamTemplate  map[string]interface{}
 	KeySourceType  string
 	KeySourceKey   string
 	KeySourcePath  string
@@ -415,6 +416,84 @@ func buildChannelAffinityKeyHint(s string) string {
 	return s[:4] + "..." + s[len(s)-4:]
 }
 
+func cloneStringAnyMap(src map[string]interface{}) map[string]interface{} {
+	if len(src) == 0 {
+		return map[string]interface{}{}
+	}
+	dst := make(map[string]interface{}, len(src))
+	for k, v := range src {
+		dst[k] = v
+	}
+	return dst
+}
+
+func mergeChannelOverride(base map[string]interface{}, tpl map[string]interface{}) map[string]interface{} {
+	if len(base) == 0 && len(tpl) == 0 {
+		return map[string]interface{}{}
+	}
+	if len(tpl) == 0 {
+		return base
+	}
+	out := cloneStringAnyMap(base)
+	for k, v := range tpl {
+		out[k] = v
+	}
+	return out
+}
+
+func appendChannelAffinityTemplateAdminInfo(c *gin.Context, meta channelAffinityMeta) {
+	if c == nil {
+		return
+	}
+	if len(meta.ParamTemplate) == 0 {
+		return
+	}
+
+	templateInfo := map[string]interface{}{
+		"applied":             true,
+		"rule_name":           meta.RuleName,
+		"param_override_keys": len(meta.ParamTemplate),
+	}
+	if anyInfo, ok := c.Get(ginKeyChannelAffinityLogInfo); ok {
+		if info, ok := anyInfo.(map[string]interface{}); ok {
+			info["override_template"] = templateInfo
+			c.Set(ginKeyChannelAffinityLogInfo, info)
+			return
+		}
+	}
+	c.Set(ginKeyChannelAffinityLogInfo, map[string]interface{}{
+		"reason":            meta.RuleName,
+		"rule_name":         meta.RuleName,
+		"using_group":       meta.UsingGroup,
+		"model":             meta.ModelName,
+		"request_path":      meta.RequestPath,
+		"key_source":        meta.KeySourceType,
+		"key_key":           meta.KeySourceKey,
+		"key_path":          meta.KeySourcePath,
+		"key_hint":          meta.KeyHint,
+		"key_fp":            meta.KeyFingerprint,
+		"override_template": templateInfo,
+	})
+}
+
+// ApplyChannelAffinityOverrideTemplate merges per-rule channel override templates onto the selected channel override config.
+func ApplyChannelAffinityOverrideTemplate(c *gin.Context, paramOverride map[string]interface{}) (map[string]interface{}, bool) {
+	if c == nil {
+		return paramOverride, false
+	}
+	meta, ok := getChannelAffinityMeta(c)
+	if !ok {
+		return paramOverride, false
+	}
+	if len(meta.ParamTemplate) == 0 {
+		return paramOverride, false
+	}
+
+	mergedParam := mergeChannelOverride(paramOverride, meta.ParamTemplate)
+	appendChannelAffinityTemplateAdminInfo(c, meta)
+	return mergedParam, true
+}
+
 func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
 	setting := operation_setting.GetChannelAffinitySetting()
 	if setting == nil || !setting.Enabled {
@@ -466,6 +545,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
 			TTLSeconds:     ttlSeconds,
 			RuleName:       rule.Name,
 			SkipRetry:      rule.SkipRetryOnFailure,
+			ParamTemplate:  cloneStringAnyMap(rule.ParamOverrideTemplate),
 			KeySourceType:  strings.TrimSpace(usedSource.Type),
 			KeySourceKey:   strings.TrimSpace(usedSource.Key),
 			KeySourcePath:  strings.TrimSpace(usedSource.Path),

+ 69 - 0
service/channel_affinity_template_test.go

@@ -0,0 +1,69 @@
+package service
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/require"
+)
+
+func buildChannelAffinityTemplateContextForTest(meta channelAffinityMeta) *gin.Context {
+	rec := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(rec)
+	setChannelAffinityContext(ctx, meta)
+	return ctx
+}
+
+func TestApplyChannelAffinityOverrideTemplate_NoTemplate(t *testing.T) {
+	ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
+		RuleName: "rule-no-template",
+	})
+	base := map[string]interface{}{
+		"temperature": 0.7,
+	}
+
+	merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
+	require.False(t, applied)
+	require.Equal(t, base, merged)
+}
+
+func TestApplyChannelAffinityOverrideTemplate_MergeTemplate(t *testing.T) {
+	ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
+		RuleName: "rule-with-template",
+		ParamTemplate: map[string]interface{}{
+			"temperature": 0.2,
+			"top_p":       0.95,
+		},
+		UsingGroup:     "default",
+		ModelName:      "gpt-4.1",
+		RequestPath:    "/v1/responses",
+		KeySourceType:  "gjson",
+		KeySourcePath:  "prompt_cache_key",
+		KeyHint:        "abcd...wxyz",
+		KeyFingerprint: "abcd1234",
+	})
+	base := map[string]interface{}{
+		"temperature": 0.7,
+		"max_tokens":  2000,
+	}
+
+	merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
+	require.True(t, applied)
+	require.Equal(t, 0.2, merged["temperature"])
+	require.Equal(t, 0.95, merged["top_p"])
+	require.Equal(t, 2000, merged["max_tokens"])
+	require.Equal(t, 0.7, base["temperature"])
+
+	anyInfo, ok := ctx.Get(ginKeyChannelAffinityLogInfo)
+	require.True(t, ok)
+	info, ok := anyInfo.(map[string]interface{})
+	require.True(t, ok)
+	overrideInfoAny, ok := info["override_template"]
+	require.True(t, ok)
+	overrideInfo, ok := overrideInfoAny.(map[string]interface{})
+	require.True(t, ok)
+	require.Equal(t, true, overrideInfo["applied"])
+	require.Equal(t, "rule-with-template", overrideInfo["rule_name"])
+	require.EqualValues(t, 2, overrideInfo["param_override_keys"])
+}

+ 56 - 14
setting/operation_setting/channel_affinity_setting.go

@@ -18,6 +18,8 @@ type ChannelAffinityRule struct {
 	ValueRegex string `json:"value_regex"`
 	TTLSeconds int    `json:"ttl_seconds"`
 
+	ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
+
 	SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
 
 	IncludeUsingGroup bool `json:"include_using_group"`
@@ -32,6 +34,44 @@ type ChannelAffinitySetting struct {
 	Rules             []ChannelAffinityRule `json:"rules"`
 }
 
+var codexCliPassThroughHeaders = []string{
+	"Originator",
+	"Session_id",
+	"User-Agent",
+	"X-Codex-Beta-Features",
+	"X-Codex-Turn-Metadata",
+}
+
+var claudeCliPassThroughHeaders = []string{
+	"X-Stainless-Arch",
+	"X-Stainless-Lang",
+	"X-Stainless-Os",
+	"X-Stainless-Package-Version",
+	"X-Stainless-Retry-Count",
+	"X-Stainless-Runtime",
+	"X-Stainless-Runtime-Version",
+	"X-Stainless-Timeout",
+	"User-Agent",
+	"X-App",
+	"Anthropic-Beta",
+	"Anthropic-Dangerous-Direct-Browser-Access",
+	"Anthropic-Version",
+}
+
+func buildPassHeaderTemplate(headers []string) map[string]interface{} {
+	clonedHeaders := make([]string, 0, len(headers))
+	clonedHeaders = append(clonedHeaders, headers...)
+	return map[string]interface{}{
+		"operations": []map[string]interface{}{
+			{
+				"mode":        "pass_headers",
+				"value":       clonedHeaders,
+				"keep_origin": true,
+			},
+		},
+	}
+}
+
 var channelAffinitySetting = ChannelAffinitySetting{
 	Enabled:           true,
 	SwitchOnSuccess:   true,
@@ -39,32 +79,34 @@ var channelAffinitySetting = ChannelAffinitySetting{
 	DefaultTTLSeconds: 3600,
 	Rules: []ChannelAffinityRule{
 		{
-			Name:       "codex trace",
+			Name:       "codex cli trace",
 			ModelRegex: []string{"^gpt-.*$"},
 			PathRegex:  []string{"/v1/responses"},
 			KeySources: []ChannelAffinityKeySource{
 				{Type: "gjson", Path: "prompt_cache_key"},
 			},
-			ValueRegex:         "",
-			TTLSeconds:         0,
-			SkipRetryOnFailure: false,
-			IncludeUsingGroup:  true,
-			IncludeRuleName:    true,
-			UserAgentInclude:   nil,
+			ValueRegex:            "",
+			TTLSeconds:            0,
+			ParamOverrideTemplate: buildPassHeaderTemplate(codexCliPassThroughHeaders),
+			SkipRetryOnFailure:    false,
+			IncludeUsingGroup:     true,
+			IncludeRuleName:       true,
+			UserAgentInclude:      nil,
 		},
 		{
-			Name:       "claude code trace",
+			Name:       "claude cli trace",
 			ModelRegex: []string{"^claude-.*$"},
 			PathRegex:  []string{"/v1/messages"},
 			KeySources: []ChannelAffinityKeySource{
 				{Type: "gjson", Path: "metadata.user_id"},
 			},
-			ValueRegex:         "",
-			TTLSeconds:         0,
-			SkipRetryOnFailure: false,
-			IncludeUsingGroup:  true,
-			IncludeRuleName:    true,
-			UserAgentInclude:   nil,
+			ValueRegex:            "",
+			TTLSeconds:            0,
+			ParamOverrideTemplate: buildPassHeaderTemplate(claudeCliPassThroughHeaders),
+			SkipRetryOnFailure:    false,
+			IncludeUsingGroup:     true,
+			IncludeRuleName:       true,
+			UserAgentInclude:      nil,
 		},
 	},
 }

+ 16 - 0
web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx

@@ -36,6 +36,10 @@ import {
 } from '@douyinfe/semi-ui';
 import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
 import { copy, showError, showSuccess, verifyJSON } from '../../../../helpers';
+import {
+  CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
+  CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
+} from '../../../../constants/channel-affinity-template.constants';
 
 const { Text } = Typography;
 
@@ -329,6 +333,18 @@ const TEMPLATE_PRESET_CONFIG = {
     kind: 'operations',
     payload: GEMINI_IMAGE_4K_TEMPLATE,
   },
+  claude_cli_headers_passthrough: {
+    group: 'scenario',
+    label: 'Claude CLI 请求头透传',
+    kind: 'operations',
+    payload: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
+  },
+  codex_cli_headers_passthrough: {
+    group: 'scenario',
+    label: 'Codex CLI 请求头透传',
+    kind: 'operations',
+    payload: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
+  },
 };
 
 const FIELD_GUIDE_TARGET_OPTIONS = [

+ 90 - 0
web/src/constants/channel-affinity-template.constants.js

@@ -0,0 +1,90 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+const buildPassHeadersTemplate = (headers) => ({
+  operations: [
+    {
+      mode: 'pass_headers',
+      value: [...headers],
+      keep_origin: true,
+    },
+  ],
+});
+
+export const CODEX_CLI_HEADER_PASSTHROUGH_HEADERS = [
+  'Originator',
+  'Session_id',
+  'User-Agent',
+  'X-Codex-Beta-Features',
+  'X-Codex-Turn-Metadata',
+];
+
+export const CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS = [
+  'X-Stainless-Arch',
+  'X-Stainless-Lang',
+  'X-Stainless-Os',
+  'X-Stainless-Package-Version',
+  'X-Stainless-Retry-Count',
+  'X-Stainless-Runtime',
+  'X-Stainless-Runtime-Version',
+  'X-Stainless-Timeout',
+  'User-Agent',
+  'X-App',
+  'Anthropic-Beta',
+  'Anthropic-Dangerous-Direct-Browser-Access',
+  'Anthropic-Version',
+];
+
+export const CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate(
+  CODEX_CLI_HEADER_PASSTHROUGH_HEADERS,
+);
+
+export const CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate(
+  CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS,
+);
+
+export const CHANNEL_AFFINITY_RULE_TEMPLATES = {
+  codexCli: {
+    name: 'codex cli trace',
+    model_regex: ['^gpt-.*$'],
+    path_regex: ['/v1/responses'],
+    key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
+    param_override_template: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
+    value_regex: '',
+    ttl_seconds: 0,
+    skip_retry_on_failure: false,
+    include_using_group: true,
+    include_rule_name: true,
+  },
+  claudeCli: {
+    name: 'claude cli trace',
+    model_regex: ['^claude-.*$'],
+    path_regex: ['/v1/messages'],
+    key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
+    param_override_template: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
+    value_regex: '',
+    ttl_seconds: 0,
+    skip_retry_on_failure: false,
+    include_using_group: true,
+    include_rule_name: true,
+  },
+};
+
+export const cloneChannelAffinityTemplate = (template) =>
+  JSON.parse(JSON.stringify(template || {}));

+ 1 - 0
web/src/constants/index.js

@@ -24,3 +24,4 @@ export * from './common.constant';
 export * from './dashboard.constants';
 export * from './playground.constants';
 export * from './redemption.constants';
+export * from './channel-affinity-template.constants';

+ 251 - 44
web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
 import {
   Banner,
   Button,
@@ -37,10 +37,12 @@ import {
 } from '@douyinfe/semi-ui';
 import {
   IconClose,
+  IconCode,
   IconDelete,
   IconEdit,
   IconPlus,
   IconRefresh,
+  IconSearch,
 } from '@douyinfe/semi-icons';
 import {
   API,
@@ -52,6 +54,11 @@ import {
   verifyJSON,
 } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
+import {
+  CHANNEL_AFFINITY_RULE_TEMPLATES,
+  cloneChannelAffinityTemplate,
+} from '../../../constants/channel-affinity-template.constants';
+import ParamOverrideEditorModal from '../../../components/table/channels/modals/ParamOverrideEditorModal';
 
 const KEY_ENABLED = 'channel_affinity_setting.enabled';
 const KEY_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
@@ -65,31 +72,6 @@ const KEY_SOURCE_TYPES = [
   { label: 'gjson', value: 'gjson' },
 ];
 
-const RULE_TEMPLATES = {
-  codex: {
-    name: 'codex trace',
-    model_regex: ['^gpt-.*$'],
-    path_regex: ['/v1/responses'],
-    key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
-    value_regex: '',
-    ttl_seconds: 0,
-    skip_retry_on_failure: false,
-    include_using_group: true,
-    include_rule_name: true,
-  },
-  claudeCode: {
-    name: 'claude-code trace',
-    model_regex: ['^claude-.*$'],
-    path_regex: ['/v1/messages'],
-    key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
-    value_regex: '',
-    ttl_seconds: 0,
-    skip_retry_on_failure: false,
-    include_using_group: true,
-    include_rule_name: true,
-  },
-};
-
 const CONTEXT_KEY_PRESETS = [
   { key: 'id', label: 'id(用户 ID)' },
   { key: 'token_id', label: 'token_id' },
@@ -114,6 +96,11 @@ const RULES_JSON_PLACEHOLDER = `[
     ],
     "value_regex": "^[-0-9A-Za-z._:]{1,128}$",
     "ttl_seconds": 600,
+    "param_override_template": {
+      "operations": [
+        { "path": "temperature", "mode": "set", "value": 0.2 }
+      ]
+    },
     "skip_retry_on_failure": false,
     "include_using_group": true,
     "include_rule_name": true
@@ -187,6 +174,23 @@ const tryParseRulesJsonArray = (jsonString) => {
   }
 };
 
+const parseOptionalObjectJson = (jsonString, label) => {
+  const raw = (jsonString || '').trim();
+  if (!raw) return { ok: true, value: null };
+  if (!verifyJSON(raw)) {
+    return { ok: false, message: `${label} JSON 格式不正确` };
+  }
+  try {
+    const parsed = JSON.parse(raw);
+    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+      return { ok: false, message: `${label} 必须是 JSON 对象` };
+    }
+    return { ok: true, value: parsed };
+  } catch (error) {
+    return { ok: false, message: `${label} JSON 格式不正确` };
+  }
+};
+
 export default function SettingsChannelAffinity(props) {
   const { t } = useTranslation();
   const { Text } = Typography;
@@ -222,6 +226,9 @@ export default function SettingsChannelAffinity(props) {
   const [modalInitValues, setModalInitValues] = useState(null);
   const [modalFormKey, setModalFormKey] = useState(0);
   const [modalAdvancedActiveKey, setModalAdvancedActiveKey] = useState([]);
+  const [paramTemplateDraft, setParamTemplateDraft] = useState('');
+  const [paramTemplateEditorVisible, setParamTemplateEditorVisible] =
+    useState(false);
 
   const effectiveDefaultTTLSeconds =
     Number(inputs?.[KEY_DEFAULT_TTL] || 0) > 0
@@ -240,9 +247,99 @@ export default function SettingsChannelAffinity(props) {
       skip_retry_on_failure: !!r.skip_retry_on_failure,
       include_using_group: r.include_using_group ?? true,
       include_rule_name: r.include_rule_name ?? true,
+      param_override_template_json: r.param_override_template
+        ? stringifyPretty(r.param_override_template)
+        : '',
     };
   };
 
+  const paramTemplatePreviewMeta = useMemo(() => {
+    const raw = (paramTemplateDraft || '').trim();
+    if (!raw) {
+      return {
+        tagLabel: t('未设置'),
+        tagColor: 'grey',
+        preview: t('当前规则未设置参数覆盖模板'),
+      };
+    }
+    if (!verifyJSON(raw)) {
+      return {
+        tagLabel: t('JSON 无效'),
+        tagColor: 'red',
+        preview: raw,
+      };
+    }
+    try {
+      return {
+        tagLabel: t('已设置'),
+        tagColor: 'orange',
+        preview: JSON.stringify(JSON.parse(raw), null, 2),
+      };
+    } catch (error) {
+      return {
+        tagLabel: t('JSON 无效'),
+        tagColor: 'red',
+        preview: raw,
+      };
+    }
+  }, [paramTemplateDraft, t]);
+
+  const updateParamTemplateDraft = (value) => {
+    const next = typeof value === 'string' ? value : '';
+    setParamTemplateDraft(next);
+    if (modalFormRef.current) {
+      modalFormRef.current.setValue('param_override_template_json', next);
+    }
+  };
+
+  const formatParamTemplateDraft = () => {
+    const raw = (paramTemplateDraft || '').trim();
+    if (!raw) return;
+    if (!verifyJSON(raw)) {
+      showError(t('参数覆盖模板 JSON 格式不正确'));
+      return;
+    }
+    try {
+      updateParamTemplateDraft(JSON.stringify(JSON.parse(raw), null, 2));
+    } catch (error) {
+      showError(t('参数覆盖模板 JSON 格式不正确'));
+    }
+  };
+
+  const openParamTemplatePreview = (rule) => {
+    const raw = rule?.param_override_template;
+    if (!raw || typeof raw !== 'object') {
+      showWarning(t('该规则未设置参数覆盖模板'));
+      return;
+    }
+    Modal.info({
+      title: t('参数覆盖模板预览'),
+      content: (
+        <div style={{ marginTop: 6, paddingBottom: 10 }}>
+          <pre
+            style={{
+              margin: 0,
+              maxHeight: 420,
+              overflow: 'auto',
+              fontSize: 12,
+              lineHeight: 1.6,
+              padding: 10,
+              borderRadius: 8,
+              background: 'var(--semi-color-fill-0)',
+              border: '1px solid var(--semi-color-border)',
+              whiteSpace: 'pre-wrap',
+              wordBreak: 'break-all',
+            }}
+          >
+            {stringifyPretty(raw)}
+          </pre>
+        </div>
+      ),
+      footer: null,
+      width: 760,
+    });
+  };
+
   const refreshCacheStats = async () => {
     try {
       setCacheLoading(true);
@@ -354,11 +451,15 @@ export default function SettingsChannelAffinity(props) {
           .filter((x) => x.length > 0),
       );
 
-      const templates = [RULE_TEMPLATES.codex, RULE_TEMPLATES.claudeCode].map(
+      const templates = [
+        CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
+        CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
+      ].map(
         (tpl) => {
+          const baseTemplate = cloneChannelAffinityTemplate(tpl);
           const name = makeUniqueName(existingNames, tpl.name);
           existingNames.add(name);
-          return { ...tpl, name };
+          return { ...baseTemplate, name };
         },
       );
 
@@ -376,7 +477,7 @@ export default function SettingsChannelAffinity(props) {
     }
 
     Modal.confirm({
-      title: t('填充 Codex / Claude Code 模版'),
+      title: t('填充 Codex CLI / Claude CLI 模版'),
       content: (
         <div style={{ lineHeight: '1.6' }}>
           <Text type='tertiary'>{t('将追加 2 条规则到现有规则列表。')}</Text>
@@ -416,18 +517,6 @@ export default function SettingsChannelAffinity(props) {
             ))
           : '-',
     },
-    {
-      title: t('User-Agent include'),
-      dataIndex: 'user_agent_include',
-      render: (list) =>
-        (list || []).length > 0
-          ? (list || []).slice(0, 2).map((v, idx) => (
-              <Tag key={`${v}-${idx}`} style={{ marginRight: 4 }}>
-                {v}
-              </Tag>
-            ))
-          : '-',
-    },
     {
       title: t('Key 来源'),
       dataIndex: 'key_sources',
@@ -450,6 +539,24 @@ export default function SettingsChannelAffinity(props) {
       dataIndex: 'ttl_seconds',
       render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
     },
+    {
+      title: t('覆盖模板'),
+      render: (_, record) => {
+        if (!record?.param_override_template) {
+          return <Text type='tertiary'>-</Text>;
+        }
+        return (
+          <Button
+            size='small'
+            icon={<IconSearch />}
+            type='tertiary'
+            onClick={() => openParamTemplatePreview(record)}
+          >
+            {t('预览模板')}
+          </Button>
+        );
+      },
+    },
     {
       title: t('缓存条目数'),
       render: (_, record) => {
@@ -539,7 +646,10 @@ export default function SettingsChannelAffinity(props) {
     setEditingRule(nextRule);
     setIsEdit(false);
     modalFormRef.current = null;
-    setModalInitValues(buildModalFormValues(nextRule));
+    const initValues = buildModalFormValues(nextRule);
+    setModalInitValues(initValues);
+    setParamTemplateDraft(initValues.param_override_template_json || '');
+    setParamTemplateEditorVisible(false);
     setModalAdvancedActiveKey([]);
     setModalFormKey((k) => k + 1);
     setModalVisible(true);
@@ -557,7 +667,10 @@ export default function SettingsChannelAffinity(props) {
     setEditingRule(nextRule);
     setIsEdit(true);
     modalFormRef.current = null;
-    setModalInitValues(buildModalFormValues(nextRule));
+    const initValues = buildModalFormValues(nextRule);
+    setModalInitValues(initValues);
+    setParamTemplateDraft(initValues.param_override_template_json || '');
+    setParamTemplateEditorVisible(false);
     setModalAdvancedActiveKey([]);
     setModalFormKey((k) => k + 1);
     setModalVisible(true);
@@ -582,6 +695,13 @@ export default function SettingsChannelAffinity(props) {
       const userAgentInclude = normalizeStringList(
         values.user_agent_include_text,
       );
+      const paramTemplateValidation = parseOptionalObjectJson(
+        paramTemplateDraft,
+        '参数覆盖模板',
+      );
+      if (!paramTemplateValidation.ok) {
+        return showError(t(paramTemplateValidation.message));
+      }
 
       const rulePayload = {
         id: isEdit ? editingRule.id : rules.length,
@@ -599,6 +719,9 @@ export default function SettingsChannelAffinity(props) {
         ...(userAgentInclude.length > 0
           ? { user_agent_include: userAgentInclude }
           : {}),
+        ...(paramTemplateValidation.value
+          ? { param_override_template: paramTemplateValidation.value }
+          : {}),
       };
 
       if (!rulePayload.name) return showError(t('名称不能为空'));
@@ -620,6 +743,8 @@ export default function SettingsChannelAffinity(props) {
       setModalVisible(false);
       setEditingRule(null);
       setModalInitValues(null);
+      setParamTemplateDraft('');
+      setParamTemplateEditorVisible(false);
       showSuccess(t('保存成功'));
     } catch (e) {
       showError(t('请检查输入'));
@@ -859,7 +984,7 @@ export default function SettingsChannelAffinity(props) {
                 {t('JSON 模式')}
               </Button>
               <Button onClick={appendCodexAndClaudeCodeTemplates}>
-                {t('填充 Codex / Claude Code 模版')}
+                {t('填充 Codex CLI / Claude CLI 模版')}
               </Button>
               <Button icon={<IconPlus />} onClick={openAddModal}>
                 {t('新增规则')}
@@ -919,6 +1044,8 @@ export default function SettingsChannelAffinity(props) {
           setEditingRule(null);
           setModalInitValues(null);
           setModalAdvancedActiveKey([]);
+          setParamTemplateDraft('');
+          setParamTemplateEditorVisible(false);
         }}
         onOk={handleModalSave}
         okText={t('保存')}
@@ -1032,6 +1159,76 @@ export default function SettingsChannelAffinity(props) {
                 </Col>
               </Row>
 
+              <Row gutter={16}>
+                <Col xs={24}>
+                  <div style={{ marginBottom: 8 }}>
+                    <Text strong>{t('参数覆盖模板')}</Text>
+                  </div>
+                  <Text type='tertiary' size='small'>
+                    {t(
+                      '命中该亲和规则后,会把此模板合并到渠道参数覆盖中(同名键由模板覆盖)。',
+                    )}
+                  </Text>
+                  <div
+                    style={{
+                      marginTop: 8,
+                      borderRadius: 10,
+                      padding: 10,
+                      background: 'var(--semi-color-fill-0)',
+                      border: '1px solid var(--semi-color-border)',
+                    }}
+                  >
+                    <div
+                      style={{
+                        display: 'flex',
+                        alignItems: 'center',
+                        justifyContent: 'space-between',
+                        marginBottom: 8,
+                        gap: 8,
+                        flexWrap: 'wrap',
+                      }}
+                    >
+                      <Tag color={paramTemplatePreviewMeta.tagColor}>
+                        {paramTemplatePreviewMeta.tagLabel}
+                      </Tag>
+                      <Space>
+                        <Button
+                          size='small'
+                          type='primary'
+                          icon={<IconCode />}
+                          onClick={() => setParamTemplateEditorVisible(true)}
+                        >
+                          {t('可视化编辑')}
+                        </Button>
+                        <Button size='small' onClick={formatParamTemplateDraft}>
+                          {t('格式化')}
+                        </Button>
+                        <Button
+                          size='small'
+                          type='tertiary'
+                          onClick={() => updateParamTemplateDraft('')}
+                        >
+                          {t('清空')}
+                        </Button>
+                      </Space>
+                    </div>
+                    <pre
+                      style={{
+                        margin: 0,
+                        maxHeight: 220,
+                        overflow: 'auto',
+                        fontSize: 12,
+                        lineHeight: 1.6,
+                        whiteSpace: 'pre-wrap',
+                        wordBreak: 'break-all',
+                      }}
+                    >
+                      {paramTemplatePreviewMeta.preview}
+                    </pre>
+                  </div>
+                </Col>
+              </Row>
+
               <Row gutter={16}>
                 <Col xs={24} sm={12}>
                   <Form.Switch
@@ -1159,6 +1356,16 @@ export default function SettingsChannelAffinity(props) {
           />
         </Form>
       </Modal>
+
+      <ParamOverrideEditorModal
+        visible={paramTemplateEditorVisible}
+        value={paramTemplateDraft || ''}
+        onSave={(nextValue) => {
+          updateParamTemplateDraft(nextValue || '');
+          setParamTemplateEditorVisible(false);
+        }}
+        onCancel={() => setParamTemplateEditorVisible(false)}
+      />
     </>
   );
 }