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
+ {stringifyPretty(raw)}
+
+
+ {paramTemplatePreviewMeta.preview}
+
+