From 051944657110c65eb3a7903632fc9b3c93534799 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 25 Feb 2026 15:08:23 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9Aadd=20CLI=20param-override=20templ?= =?UTF-8?q?ates=20with=20visual=20editor=20and=20apply=20on=20first=20rule?= =?UTF-8?q?=20match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware/distributor.go | 9 +- service/channel_affinity.go | 80 +++++ service/channel_affinity_template_test.go | 69 ++++ .../channel_affinity_setting.go | 70 ++++- .../modals/ParamOverrideEditorModal.jsx | 16 + .../channel-affinity-template.constants.js | 90 ++++++ web/src/constants/index.js | 1 + .../Operation/SettingsChannelAffinity.jsx | 295 +++++++++++++++--- 8 files changed, 570 insertions(+), 60 deletions(-) create mode 100644 service/channel_affinity_template_test.go create mode 100644 web/src/constants/channel-affinity-template.constants.js diff --git a/middleware/distributor.go b/middleware/distributor.go index 9e66cb8f9..db57998ca 100644 --- a/middleware/distributor.go +++ b/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) } diff --git a/service/channel_affinity.go b/service/channel_affinity.go index 524c6574a..3e90b9c22 100644 --- a/service/channel_affinity.go +++ b/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), diff --git a/service/channel_affinity_template_test.go b/service/channel_affinity_template_test.go new file mode 100644 index 000000000..71e29d668 --- /dev/null +++ b/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"]) +} diff --git a/setting/operation_setting/channel_affinity_setting.go b/setting/operation_setting/channel_affinity_setting.go index 22f19824f..7727315ac 100644 --- a/setting/operation_setting/channel_affinity_setting.go +++ b/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, }, }, } diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index 832c75833..8dfd0d51e 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/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 = [ diff --git a/web/src/constants/channel-affinity-template.constants.js b/web/src/constants/channel-affinity-template.constants.js new file mode 100644 index 000000000..4bae7d0b3 --- /dev/null +++ b/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 . + +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 || {})); diff --git a/web/src/constants/index.js b/web/src/constants/index.js index 623885d44..23c07e89a 100644 --- a/web/src/constants/index.js +++ b/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'; diff --git a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx index 18e2cfbdc..dcc8686f3 100644 --- a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx +++ b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . 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: ( +
+
+            {stringifyPretty(raw)}
+          
+
+ ), + 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: (
{t('将追加 2 条规则到现有规则列表。')} @@ -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) => ( - - {v} - - )) - : '-', - }, { title: t('Key 来源'), dataIndex: 'key_sources', @@ -450,6 +539,24 @@ export default function SettingsChannelAffinity(props) { dataIndex: 'ttl_seconds', render: (v) => {Number(v || 0) || '-'}, }, + { + title: t('覆盖模板'), + render: (_, record) => { + if (!record?.param_override_template) { + return -; + } + return ( + + ); + }, + }, { 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 模式')} + + + +
+
+                      {paramTemplatePreviewMeta.preview}
+                    
+ + + + + + { + updateParamTemplateDraft(nextValue || ''); + setParamTemplateEditorVisible(false); + }} + onCancel={() => setParamTemplateEditorVisible(false)} + /> ); }