Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
e027f382
authored
Feb 25, 2026
by
Seefs
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat:add CLI param-override templates with visual editor and apply on first rule match
parent
0f09dbda
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
558 additions
and
48 deletions
+558
-48
middleware/distributor.go
+7
-2
service/channel_affinity.go
+80
-0
service/channel_affinity_template_test.go
+69
-0
setting/operation_setting/channel_affinity_setting.go
+44
-2
web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx
+16
-0
web/src/constants/channel-affinity-template.constants.js
+90
-0
web/src/constants/index.js
+1
-0
web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
+251
-44
No files found.
middleware/distributor.go
View file @
e027f382
...
@@ -348,8 +348,13 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
...
@@ -348,8 +348,13 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelCreateTime
,
channel
.
CreatedTime
)
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelCreateTime
,
channel
.
CreatedTime
)
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelSetting
,
channel
.
GetSetting
())
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelSetting
,
channel
.
GetSetting
())
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelOtherSetting
,
channel
.
GetOtherSettings
())
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelOtherSetting
,
channel
.
GetOtherSettings
())
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelParamOverride
,
channel
.
GetParamOverride
())
paramOverride
:=
channel
.
GetParamOverride
()
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelHeaderOverride
,
channel
.
GetHeaderOverride
())
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
!=
""
{
if
nil
!=
channel
.
OpenAIOrganization
&&
*
channel
.
OpenAIOrganization
!=
""
{
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelOrganization
,
*
channel
.
OpenAIOrganization
)
common
.
SetContextKey
(
c
,
constant
.
ContextKeyChannelOrganization
,
*
channel
.
OpenAIOrganization
)
}
}
...
...
service/channel_affinity.go
View file @
e027f382
...
@@ -45,6 +45,7 @@ type channelAffinityMeta struct {
...
@@ -45,6 +45,7 @@ type channelAffinityMeta struct {
TTLSeconds
int
TTLSeconds
int
RuleName
string
RuleName
string
SkipRetry
bool
SkipRetry
bool
ParamTemplate
map
[
string
]
interface
{}
KeySourceType
string
KeySourceType
string
KeySourceKey
string
KeySourceKey
string
KeySourcePath
string
KeySourcePath
string
...
@@ -415,6 +416,84 @@ func buildChannelAffinityKeyHint(s string) string {
...
@@ -415,6 +416,84 @@ func buildChannelAffinityKeyHint(s string) string {
return
s
[
:
4
]
+
"..."
+
s
[
len
(
s
)
-
4
:
]
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
)
{
func
GetPreferredChannelByAffinity
(
c
*
gin
.
Context
,
modelName
string
,
usingGroup
string
)
(
int
,
bool
)
{
setting
:=
operation_setting
.
GetChannelAffinitySetting
()
setting
:=
operation_setting
.
GetChannelAffinitySetting
()
if
setting
==
nil
||
!
setting
.
Enabled
{
if
setting
==
nil
||
!
setting
.
Enabled
{
...
@@ -466,6 +545,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
...
@@ -466,6 +545,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
TTLSeconds
:
ttlSeconds
,
TTLSeconds
:
ttlSeconds
,
RuleName
:
rule
.
Name
,
RuleName
:
rule
.
Name
,
SkipRetry
:
rule
.
SkipRetryOnFailure
,
SkipRetry
:
rule
.
SkipRetryOnFailure
,
ParamTemplate
:
cloneStringAnyMap
(
rule
.
ParamOverrideTemplate
),
KeySourceType
:
strings
.
TrimSpace
(
usedSource
.
Type
),
KeySourceType
:
strings
.
TrimSpace
(
usedSource
.
Type
),
KeySourceKey
:
strings
.
TrimSpace
(
usedSource
.
Key
),
KeySourceKey
:
strings
.
TrimSpace
(
usedSource
.
Key
),
KeySourcePath
:
strings
.
TrimSpace
(
usedSource
.
Path
),
KeySourcePath
:
strings
.
TrimSpace
(
usedSource
.
Path
),
...
...
service/channel_affinity_template_test.go
0 → 100644
View file @
e027f382
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"
])
}
setting/operation_setting/channel_affinity_setting.go
View file @
e027f382
...
@@ -18,6 +18,8 @@ type ChannelAffinityRule struct {
...
@@ -18,6 +18,8 @@ type ChannelAffinityRule struct {
ValueRegex
string
`json:"value_regex"`
ValueRegex
string
`json:"value_regex"`
TTLSeconds
int
`json:"ttl_seconds"`
TTLSeconds
int
`json:"ttl_seconds"`
ParamOverrideTemplate
map
[
string
]
interface
{}
`json:"param_override_template,omitempty"`
SkipRetryOnFailure
bool
`json:"skip_retry_on_failure,omitempty"`
SkipRetryOnFailure
bool
`json:"skip_retry_on_failure,omitempty"`
IncludeUsingGroup
bool
`json:"include_using_group"`
IncludeUsingGroup
bool
`json:"include_using_group"`
...
@@ -32,6 +34,44 @@ type ChannelAffinitySetting struct {
...
@@ -32,6 +34,44 @@ type ChannelAffinitySetting struct {
Rules
[]
ChannelAffinityRule
`json:"rules"`
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
{
var
channelAffinitySetting
=
ChannelAffinitySetting
{
Enabled
:
true
,
Enabled
:
true
,
SwitchOnSuccess
:
true
,
SwitchOnSuccess
:
true
,
...
@@ -39,7 +79,7 @@ var channelAffinitySetting = ChannelAffinitySetting{
...
@@ -39,7 +79,7 @@ var channelAffinitySetting = ChannelAffinitySetting{
DefaultTTLSeconds
:
3600
,
DefaultTTLSeconds
:
3600
,
Rules
:
[]
ChannelAffinityRule
{
Rules
:
[]
ChannelAffinityRule
{
{
{
Name
:
"codex trace"
,
Name
:
"codex
cli
trace"
,
ModelRegex
:
[]
string
{
"^gpt-.*$"
},
ModelRegex
:
[]
string
{
"^gpt-.*$"
},
PathRegex
:
[]
string
{
"/v1/responses"
},
PathRegex
:
[]
string
{
"/v1/responses"
},
KeySources
:
[]
ChannelAffinityKeySource
{
KeySources
:
[]
ChannelAffinityKeySource
{
...
@@ -47,13 +87,14 @@ var channelAffinitySetting = ChannelAffinitySetting{
...
@@ -47,13 +87,14 @@ var channelAffinitySetting = ChannelAffinitySetting{
},
},
ValueRegex
:
""
,
ValueRegex
:
""
,
TTLSeconds
:
0
,
TTLSeconds
:
0
,
ParamOverrideTemplate
:
buildPassHeaderTemplate
(
codexCliPassThroughHeaders
),
SkipRetryOnFailure
:
false
,
SkipRetryOnFailure
:
false
,
IncludeUsingGroup
:
true
,
IncludeUsingGroup
:
true
,
IncludeRuleName
:
true
,
IncludeRuleName
:
true
,
UserAgentInclude
:
nil
,
UserAgentInclude
:
nil
,
},
},
{
{
Name
:
"claude c
ode
trace"
,
Name
:
"claude c
li
trace"
,
ModelRegex
:
[]
string
{
"^claude-.*$"
},
ModelRegex
:
[]
string
{
"^claude-.*$"
},
PathRegex
:
[]
string
{
"/v1/messages"
},
PathRegex
:
[]
string
{
"/v1/messages"
},
KeySources
:
[]
ChannelAffinityKeySource
{
KeySources
:
[]
ChannelAffinityKeySource
{
...
@@ -61,6 +102,7 @@ var channelAffinitySetting = ChannelAffinitySetting{
...
@@ -61,6 +102,7 @@ var channelAffinitySetting = ChannelAffinitySetting{
},
},
ValueRegex
:
""
,
ValueRegex
:
""
,
TTLSeconds
:
0
,
TTLSeconds
:
0
,
ParamOverrideTemplate
:
buildPassHeaderTemplate
(
claudeCliPassThroughHeaders
),
SkipRetryOnFailure
:
false
,
SkipRetryOnFailure
:
false
,
IncludeUsingGroup
:
true
,
IncludeUsingGroup
:
true
,
IncludeRuleName
:
true
,
IncludeRuleName
:
true
,
...
...
web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx
View file @
e027f382
...
@@ -36,6 +36,10 @@ import {
...
@@ -36,6 +36,10 @@ import {
}
from
'@douyinfe/semi-ui'
;
}
from
'@douyinfe/semi-ui'
;
import
{
IconDelete
,
IconPlus
}
from
'@douyinfe/semi-icons'
;
import
{
IconDelete
,
IconPlus
}
from
'@douyinfe/semi-icons'
;
import
{
copy
,
showError
,
showSuccess
,
verifyJSON
}
from
'../../../../helpers'
;
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
;
const
{
Text
}
=
Typography
;
...
@@ -329,6 +333,18 @@ const TEMPLATE_PRESET_CONFIG = {
...
@@ -329,6 +333,18 @@ const TEMPLATE_PRESET_CONFIG = {
kind
:
'operations'
,
kind
:
'operations'
,
payload
:
GEMINI_IMAGE_4K_TEMPLATE
,
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
=
[
const
FIELD_GUIDE_TARGET_OPTIONS
=
[
...
...
web/src/constants/channel-affinity-template.constants.js
0 → 100644
View file @
e027f382
/*
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
||
{}));
web/src/constants/index.js
View file @
e027f382
...
@@ -24,3 +24,4 @@ export * from './common.constant';
...
@@ -24,3 +24,4 @@ export * from './common.constant';
export
*
from
'./dashboard.constants'
;
export
*
from
'./dashboard.constants'
;
export
*
from
'./playground.constants'
;
export
*
from
'./playground.constants'
;
export
*
from
'./redemption.constants'
;
export
*
from
'./redemption.constants'
;
export
*
from
'./channel-affinity-template.constants'
;
web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
View file @
e027f382
...
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
...
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
For commercial licensing, please contact support@quantumnous.com
*/
*/
import
React
,
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
React
,
{
useEffect
,
use
Memo
,
use
Ref
,
useState
}
from
'react'
;
import
{
import
{
Banner
,
Banner
,
Button
,
Button
,
...
@@ -37,10 +37,12 @@ import {
...
@@ -37,10 +37,12 @@ import {
}
from
'@douyinfe/semi-ui'
;
}
from
'@douyinfe/semi-ui'
;
import
{
import
{
IconClose
,
IconClose
,
IconCode
,
IconDelete
,
IconDelete
,
IconEdit
,
IconEdit
,
IconPlus
,
IconPlus
,
IconRefresh
,
IconRefresh
,
IconSearch
,
}
from
'@douyinfe/semi-icons'
;
}
from
'@douyinfe/semi-icons'
;
import
{
import
{
API
,
API
,
...
@@ -52,6 +54,11 @@ import {
...
@@ -52,6 +54,11 @@ import {
verifyJSON
,
verifyJSON
,
}
from
'../../../helpers'
;
}
from
'../../../helpers'
;
import
{
useTranslation
}
from
'react-i18next'
;
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_ENABLED
=
'channel_affinity_setting.enabled'
;
const
KEY_SWITCH_ON_SUCCESS
=
'channel_affinity_setting.switch_on_success'
;
const
KEY_SWITCH_ON_SUCCESS
=
'channel_affinity_setting.switch_on_success'
;
...
@@ -65,31 +72,6 @@ const KEY_SOURCE_TYPES = [
...
@@ -65,31 +72,6 @@ const KEY_SOURCE_TYPES = [
{
label
:
'gjson'
,
value
:
'gjson'
},
{
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
=
[
const
CONTEXT_KEY_PRESETS
=
[
{
key
:
'id'
,
label
:
'id(用户 ID)'
},
{
key
:
'id'
,
label
:
'id(用户 ID)'
},
{
key
:
'token_id'
,
label
:
'token_id'
},
{
key
:
'token_id'
,
label
:
'token_id'
},
...
@@ -114,6 +96,11 @@ const RULES_JSON_PLACEHOLDER = `[
...
@@ -114,6 +96,11 @@ const RULES_JSON_PLACEHOLDER = `[
],
],
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
"ttl_seconds": 600,
"ttl_seconds": 600,
"param_override_template": {
"operations": [
{ "path": "temperature", "mode": "set", "value": 0.2 }
]
},
"skip_retry_on_failure": false,
"skip_retry_on_failure": false,
"include_using_group": true,
"include_using_group": true,
"include_rule_name": true
"include_rule_name": true
...
@@ -187,6 +174,23 @@ const tryParseRulesJsonArray = (jsonString) => {
...
@@ -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
)
{
export
default
function
SettingsChannelAffinity
(
props
)
{
const
{
t
}
=
useTranslation
();
const
{
t
}
=
useTranslation
();
const
{
Text
}
=
Typography
;
const
{
Text
}
=
Typography
;
...
@@ -222,6 +226,9 @@ export default function SettingsChannelAffinity(props) {
...
@@ -222,6 +226,9 @@ export default function SettingsChannelAffinity(props) {
const
[
modalInitValues
,
setModalInitValues
]
=
useState
(
null
);
const
[
modalInitValues
,
setModalInitValues
]
=
useState
(
null
);
const
[
modalFormKey
,
setModalFormKey
]
=
useState
(
0
);
const
[
modalFormKey
,
setModalFormKey
]
=
useState
(
0
);
const
[
modalAdvancedActiveKey
,
setModalAdvancedActiveKey
]
=
useState
([]);
const
[
modalAdvancedActiveKey
,
setModalAdvancedActiveKey
]
=
useState
([]);
const
[
paramTemplateDraft
,
setParamTemplateDraft
]
=
useState
(
''
);
const
[
paramTemplateEditorVisible
,
setParamTemplateEditorVisible
]
=
useState
(
false
);
const
effectiveDefaultTTLSeconds
=
const
effectiveDefaultTTLSeconds
=
Number
(
inputs
?.[
KEY_DEFAULT_TTL
]
||
0
)
>
0
Number
(
inputs
?.[
KEY_DEFAULT_TTL
]
||
0
)
>
0
...
@@ -240,9 +247,99 @@ export default function SettingsChannelAffinity(props) {
...
@@ -240,9 +247,99 @@ export default function SettingsChannelAffinity(props) {
skip_retry_on_failure
:
!!
r
.
skip_retry_on_failure
,
skip_retry_on_failure
:
!!
r
.
skip_retry_on_failure
,
include_using_group
:
r
.
include_using_group
??
true
,
include_using_group
:
r
.
include_using_group
??
true
,
include_rule_name
:
r
.
include_rule_name
??
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
()
=>
{
const
refreshCacheStats
=
async
()
=>
{
try
{
try
{
setCacheLoading
(
true
);
setCacheLoading
(
true
);
...
@@ -354,11 +451,15 @@ export default function SettingsChannelAffinity(props) {
...
@@ -354,11 +451,15 @@ export default function SettingsChannelAffinity(props) {
.
filter
((
x
)
=>
x
.
length
>
0
),
.
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
)
=>
{
(
tpl
)
=>
{
const
baseTemplate
=
cloneChannelAffinityTemplate
(
tpl
);
const
name
=
makeUniqueName
(
existingNames
,
tpl
.
name
);
const
name
=
makeUniqueName
(
existingNames
,
tpl
.
name
);
existingNames
.
add
(
name
);
existingNames
.
add
(
name
);
return
{
...
tpl
,
name
};
return
{
...
baseTemplate
,
name
};
},
},
);
);
...
@@ -376,7 +477,7 @@ export default function SettingsChannelAffinity(props) {
...
@@ -376,7 +477,7 @@ export default function SettingsChannelAffinity(props) {
}
}
Modal
.
confirm
({
Modal
.
confirm
({
title
:
t
(
'填充 Codex
/ Claude Code
模版'
),
title
:
t
(
'填充 Codex
CLI / Claude CLI
模版'
),
content
:
(
content
:
(
<
div
style=
{
{
lineHeight
:
'1.6'
}
}
>
<
div
style=
{
{
lineHeight
:
'1.6'
}
}
>
<
Text
type=
'tertiary'
>
{
t
(
'将追加 2 条规则到现有规则列表。'
)
}
</
Text
>
<
Text
type=
'tertiary'
>
{
t
(
'将追加 2 条规则到现有规则列表。'
)
}
</
Text
>
...
@@ -417,18 +518,6 @@ export default function SettingsChannelAffinity(props) {
...
@@ -417,18 +518,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 来源'
),
title
:
t
(
'Key 来源'
),
dataIndex
:
'key_sources'
,
dataIndex
:
'key_sources'
,
render
:
(
list
)
=>
{
render
:
(
list
)
=>
{
...
@@ -451,6 +540,24 @@ export default function SettingsChannelAffinity(props) {
...
@@ -451,6 +540,24 @@ export default function SettingsChannelAffinity(props) {
render
:
(
v
)
=>
<
Text
>
{
Number
(
v
||
0
)
||
'-'
}
</
Text
>,
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
(
'缓存条目数'
),
title
:
t
(
'缓存条目数'
),
render
:
(
_
,
record
)
=>
{
render
:
(
_
,
record
)
=>
{
const
name
=
(
record
?.
name
||
''
).
trim
();
const
name
=
(
record
?.
name
||
''
).
trim
();
...
@@ -539,7 +646,10 @@ export default function SettingsChannelAffinity(props) {
...
@@ -539,7 +646,10 @@ export default function SettingsChannelAffinity(props) {
setEditingRule
(
nextRule
);
setEditingRule
(
nextRule
);
setIsEdit
(
false
);
setIsEdit
(
false
);
modalFormRef
.
current
=
null
;
modalFormRef
.
current
=
null
;
setModalInitValues
(
buildModalFormValues
(
nextRule
));
const
initValues
=
buildModalFormValues
(
nextRule
);
setModalInitValues
(
initValues
);
setParamTemplateDraft
(
initValues
.
param_override_template_json
||
''
);
setParamTemplateEditorVisible
(
false
);
setModalAdvancedActiveKey
([]);
setModalAdvancedActiveKey
([]);
setModalFormKey
((
k
)
=>
k
+
1
);
setModalFormKey
((
k
)
=>
k
+
1
);
setModalVisible
(
true
);
setModalVisible
(
true
);
...
@@ -557,7 +667,10 @@ export default function SettingsChannelAffinity(props) {
...
@@ -557,7 +667,10 @@ export default function SettingsChannelAffinity(props) {
setEditingRule
(
nextRule
);
setEditingRule
(
nextRule
);
setIsEdit
(
true
);
setIsEdit
(
true
);
modalFormRef
.
current
=
null
;
modalFormRef
.
current
=
null
;
setModalInitValues
(
buildModalFormValues
(
nextRule
));
const
initValues
=
buildModalFormValues
(
nextRule
);
setModalInitValues
(
initValues
);
setParamTemplateDraft
(
initValues
.
param_override_template_json
||
''
);
setParamTemplateEditorVisible
(
false
);
setModalAdvancedActiveKey
([]);
setModalAdvancedActiveKey
([]);
setModalFormKey
((
k
)
=>
k
+
1
);
setModalFormKey
((
k
)
=>
k
+
1
);
setModalVisible
(
true
);
setModalVisible
(
true
);
...
@@ -582,6 +695,13 @@ export default function SettingsChannelAffinity(props) {
...
@@ -582,6 +695,13 @@ export default function SettingsChannelAffinity(props) {
const
userAgentInclude
=
normalizeStringList
(
const
userAgentInclude
=
normalizeStringList
(
values
.
user_agent_include_text
,
values
.
user_agent_include_text
,
);
);
const
paramTemplateValidation
=
parseOptionalObjectJson
(
paramTemplateDraft
,
'参数覆盖模板'
,
);
if
(
!
paramTemplateValidation
.
ok
)
{
return
showError
(
t
(
paramTemplateValidation
.
message
));
}
const
rulePayload
=
{
const
rulePayload
=
{
id
:
isEdit
?
editingRule
.
id
:
rules
.
length
,
id
:
isEdit
?
editingRule
.
id
:
rules
.
length
,
...
@@ -599,6 +719,9 @@ export default function SettingsChannelAffinity(props) {
...
@@ -599,6 +719,9 @@ export default function SettingsChannelAffinity(props) {
...(
userAgentInclude
.
length
>
0
...(
userAgentInclude
.
length
>
0
?
{
user_agent_include
:
userAgentInclude
}
?
{
user_agent_include
:
userAgentInclude
}
:
{}),
:
{}),
...(
paramTemplateValidation
.
value
?
{
param_override_template
:
paramTemplateValidation
.
value
}
:
{}),
};
};
if
(
!
rulePayload
.
name
)
return
showError
(
t
(
'名称不能为空'
));
if
(
!
rulePayload
.
name
)
return
showError
(
t
(
'名称不能为空'
));
...
@@ -620,6 +743,8 @@ export default function SettingsChannelAffinity(props) {
...
@@ -620,6 +743,8 @@ export default function SettingsChannelAffinity(props) {
setModalVisible
(
false
);
setModalVisible
(
false
);
setEditingRule
(
null
);
setEditingRule
(
null
);
setModalInitValues
(
null
);
setModalInitValues
(
null
);
setParamTemplateDraft
(
''
);
setParamTemplateEditorVisible
(
false
);
showSuccess
(
t
(
'保存成功'
));
showSuccess
(
t
(
'保存成功'
));
}
catch
(
e
)
{
}
catch
(
e
)
{
showError
(
t
(
'请检查输入'
));
showError
(
t
(
'请检查输入'
));
...
@@ -859,7 +984,7 @@ export default function SettingsChannelAffinity(props) {
...
@@ -859,7 +984,7 @@ export default function SettingsChannelAffinity(props) {
{
t
(
'JSON 模式'
)
}
{
t
(
'JSON 模式'
)
}
</
Button
>
</
Button
>
<
Button
onClick=
{
appendCodexAndClaudeCodeTemplates
}
>
<
Button
onClick=
{
appendCodexAndClaudeCodeTemplates
}
>
{
t
(
'填充 Codex
/ Claude Code
模版'
)
}
{
t
(
'填充 Codex
CLI / Claude CLI
模版'
)
}
</
Button
>
</
Button
>
<
Button
icon=
{
<
IconPlus
/>
}
onClick=
{
openAddModal
}
>
<
Button
icon=
{
<
IconPlus
/>
}
onClick=
{
openAddModal
}
>
{
t
(
'新增规则'
)
}
{
t
(
'新增规则'
)
}
...
@@ -919,6 +1044,8 @@ export default function SettingsChannelAffinity(props) {
...
@@ -919,6 +1044,8 @@ export default function SettingsChannelAffinity(props) {
setEditingRule
(
null
);
setEditingRule
(
null
);
setModalInitValues
(
null
);
setModalInitValues
(
null
);
setModalAdvancedActiveKey
([]);
setModalAdvancedActiveKey
([]);
setParamTemplateDraft
(
''
);
setParamTemplateEditorVisible
(
false
);
}
}
}
}
onOk=
{
handleModalSave
}
onOk=
{
handleModalSave
}
okText=
{
t
(
'保存'
)
}
okText=
{
t
(
'保存'
)
}
...
@@ -1033,6 +1160,76 @@ export default function SettingsChannelAffinity(props) {
...
@@ -1033,6 +1160,76 @@ export default function SettingsChannelAffinity(props) {
</
Row
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
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
}
>
<
Col
xs=
{
24
}
sm=
{
12
}
>
<
Form
.
Switch
<
Form
.
Switch
field=
'include_using_group'
field=
'include_using_group'
...
@@ -1159,6 +1356,16 @@ export default function SettingsChannelAffinity(props) {
...
@@ -1159,6 +1356,16 @@ export default function SettingsChannelAffinity(props) {
/>
/>
</
Form
>
</
Form
>
</
Modal
>
</
Modal
>
<
ParamOverrideEditorModal
visible=
{
paramTemplateEditorVisible
}
value=
{
paramTemplateDraft
||
''
}
onSave=
{
(
nextValue
)
=>
{
updateParamTemplateDraft
(
nextValue
||
''
);
setParamTemplateEditorVisible
(
false
);
}
}
onCancel=
{
()
=>
setParamTemplateEditorVisible
(
false
)
}
/>
</>
</>
);
);
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment