mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 04:22:58 +00:00
Merge pull request #3009 from seefs001/feature/improve-param-override
feat: improve channel override ui/ux
This commit is contained in:
@@ -366,7 +366,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
|||||||
newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
|
newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jsonData, err := json.Marshal(convertedRequest)
|
jsonData, err := common.Marshal(convertedRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return testResult{
|
return testResult{
|
||||||
context: c,
|
context: c,
|
||||||
@@ -385,8 +385,15 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
|||||||
//}
|
//}
|
||||||
|
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if fixedErr, ok := relaycommon.AsParamOverrideReturnError(err); ok {
|
||||||
|
return testResult{
|
||||||
|
context: c,
|
||||||
|
localErr: fixedErr,
|
||||||
|
newAPIError: relaycommon.NewAPIErrorFromParamOverride(fixedErr),
|
||||||
|
}
|
||||||
|
}
|
||||||
return testResult{
|
return testResult{
|
||||||
context: c,
|
context: c,
|
||||||
localErr: err,
|
localErr: err,
|
||||||
|
|||||||
@@ -182,8 +182,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
|||||||
ModelName: relayInfo.OriginModelName,
|
ModelName: relayInfo.OriginModelName,
|
||||||
Retry: common.GetPointer(0),
|
Retry: common.GetPointer(0),
|
||||||
}
|
}
|
||||||
|
relayInfo.RetryIndex = 0
|
||||||
|
relayInfo.LastError = nil
|
||||||
|
|
||||||
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
|
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
|
||||||
|
relayInfo.RetryIndex = retryParam.GetRetry()
|
||||||
channel, channelErr := getChannel(c, relayInfo, retryParam)
|
channel, channelErr := getChannel(c, relayInfo, retryParam)
|
||||||
if channelErr != nil {
|
if channelErr != nil {
|
||||||
logger.LogError(c, channelErr.Error())
|
logger.LogError(c, channelErr.Error())
|
||||||
@@ -216,10 +219,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if newAPIError == nil {
|
if newAPIError == nil {
|
||||||
|
relayInfo.LastError = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newAPIError = service.NormalizeViolationFeeError(newAPIError)
|
newAPIError = service.NormalizeViolationFeeError(newAPIError)
|
||||||
|
relayInfo.LastError = newAPIError
|
||||||
|
|
||||||
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
|
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,12 +169,17 @@ func applyHeaderOverridePlaceholders(template string, c *gin.Context, apiKey str
|
|||||||
// Passthrough rules are applied first, then normal overrides are applied, so explicit overrides win.
|
// Passthrough rules are applied first, then normal overrides are applied, so explicit overrides win.
|
||||||
func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]string, error) {
|
func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]string, error) {
|
||||||
headerOverride := make(map[string]string)
|
headerOverride := make(map[string]string)
|
||||||
|
if info == nil {
|
||||||
|
return headerOverride, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
headerOverrideSource := common.GetEffectiveHeaderOverride(info)
|
||||||
|
|
||||||
passAll := false
|
passAll := false
|
||||||
var passthroughRegex []*regexp.Regexp
|
var passthroughRegex []*regexp.Regexp
|
||||||
if !info.IsChannelTest {
|
if !info.IsChannelTest {
|
||||||
for k := range info.HeadersOverride {
|
for k := range headerOverrideSource {
|
||||||
key := strings.TrimSpace(k)
|
key := strings.TrimSpace(strings.ToLower(k))
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -183,12 +188,11 @@ func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]s
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lower := strings.ToLower(key)
|
|
||||||
var pattern string
|
var pattern string
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(lower, headerPassthroughRegexPrefix):
|
case strings.HasPrefix(key, headerPassthroughRegexPrefix):
|
||||||
pattern = strings.TrimSpace(key[len(headerPassthroughRegexPrefix):])
|
pattern = strings.TrimSpace(key[len(headerPassthroughRegexPrefix):])
|
||||||
case strings.HasPrefix(lower, headerPassthroughRegexPrefixV2):
|
case strings.HasPrefix(key, headerPassthroughRegexPrefixV2):
|
||||||
pattern = strings.TrimSpace(key[len(headerPassthroughRegexPrefixV2):])
|
pattern = strings.TrimSpace(key[len(headerPassthroughRegexPrefixV2):])
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
@@ -229,15 +233,15 @@ func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]s
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
headerOverride[name] = value
|
headerOverride[strings.ToLower(strings.TrimSpace(name))] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range info.HeadersOverride {
|
for k, v := range headerOverrideSource {
|
||||||
if isHeaderPassthroughRuleKey(k) {
|
if isHeaderPassthroughRuleKey(k) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := strings.TrimSpace(k)
|
key := strings.TrimSpace(strings.ToLower(k))
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestProcessHeaderOverride_ChannelTestSkipsClientHeaderPlaceholder(t *testin
|
|||||||
|
|
||||||
headers, err := processHeaderOverride(info, ctx)
|
headers, err := processHeaderOverride(info, ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, ok := headers["X-Upstream-Trace"]
|
_, ok := headers["x-upstream-trace"]
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,38 @@ func TestProcessHeaderOverride_NonTestKeepsClientHeaderPlaceholder(t *testing.T)
|
|||||||
|
|
||||||
headers, err := processHeaderOverride(info, ctx)
|
headers, err := processHeaderOverride(info, ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "trace-123", headers["X-Upstream-Trace"])
|
require.Equal(t, "trace-123", headers["x-upstream-trace"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHeaderOverride_RuntimeOverrideIsFinalHeaderMap(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
IsChannelTest: false,
|
||||||
|
UseRuntimeHeadersOverride: true,
|
||||||
|
RuntimeHeadersOverride: map[string]any{
|
||||||
|
"x-static": "runtime-value",
|
||||||
|
"x-runtime": "runtime-only",
|
||||||
|
},
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
HeadersOverride: map[string]any{
|
||||||
|
"X-Static": "legacy-value",
|
||||||
|
"X-Legacy": "legacy-only",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
headers, err := processHeaderOverride(info, ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "runtime-value", headers["x-static"])
|
||||||
|
require.Equal(t, "runtime-only", headers["x-runtime"])
|
||||||
|
_, exists := headers["x-legacy"]
|
||||||
|
require.False(t, exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessHeaderOverride_PassthroughSkipsAcceptEncoding(t *testing.T) {
|
func TestProcessHeaderOverride_PassthroughSkipsAcceptEncoding(t *testing.T) {
|
||||||
@@ -101,8 +132,62 @@ func TestProcessHeaderOverride_PassthroughSkipsAcceptEncoding(t *testing.T) {
|
|||||||
|
|
||||||
headers, err := processHeaderOverride(info, ctx)
|
headers, err := processHeaderOverride(info, ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "trace-123", headers["X-Trace-Id"])
|
require.Equal(t, "trace-123", headers["x-trace-id"])
|
||||||
|
|
||||||
_, hasAcceptEncoding := headers["Accept-Encoding"]
|
_, hasAcceptEncoding := headers["accept-encoding"]
|
||||||
require.False(t, hasAcceptEncoding)
|
require.False(t, hasAcceptEncoding)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessHeaderOverride_PassHeadersTemplateSetsRuntimeHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||||
|
ctx.Request.Header.Set("Originator", "Codex CLI")
|
||||||
|
ctx.Request.Header.Set("Session_id", "sess-123")
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
IsChannelTest: false,
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Originator": "Codex CLI",
|
||||||
|
"Session_id": "sess-123",
|
||||||
|
},
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
ParamOverride: map[string]any{
|
||||||
|
"operations": []any{
|
||||||
|
map[string]any{
|
||||||
|
"mode": "pass_headers",
|
||||||
|
"value": []any{"Originator", "Session_id", "X-Codex-Beta-Features"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HeadersOverride: map[string]any{
|
||||||
|
"X-Static": "legacy-value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := relaycommon.ApplyParamOverrideWithRelayInfo([]byte(`{"model":"gpt-4.1"}`), info)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, info.UseRuntimeHeadersOverride)
|
||||||
|
require.Equal(t, "Codex CLI", info.RuntimeHeadersOverride["originator"])
|
||||||
|
require.Equal(t, "sess-123", info.RuntimeHeadersOverride["session_id"])
|
||||||
|
_, exists := info.RuntimeHeadersOverride["x-codex-beta-features"]
|
||||||
|
require.False(t, exists)
|
||||||
|
require.Equal(t, "legacy-value", info.RuntimeHeadersOverride["x-static"])
|
||||||
|
|
||||||
|
headers, err := processHeaderOverride(info, ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Codex CLI", headers["originator"])
|
||||||
|
require.Equal(t, "sess-123", headers["session_id"])
|
||||||
|
_, exists = headers["x-codex-beta-features"]
|
||||||
|
require.False(t, exists)
|
||||||
|
|
||||||
|
upstreamReq := httptest.NewRequest(http.MethodPost, "https://example.com/v1/responses", nil)
|
||||||
|
applyHeaderOverrideToRequest(upstreamReq, headers)
|
||||||
|
require.Equal(t, "Codex CLI", upstreamReq.Header.Get("Originator"))
|
||||||
|
require.Equal(t, "sess-123", upstreamReq.Header.Get("Session_id"))
|
||||||
|
require.Empty(t, upstreamReq.Header.Get("X-Codex-Beta-Features"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) {
|
func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) {
|
||||||
overrideCtx := relaycommon.BuildParamOverrideContext(info)
|
|
||||||
chatJSON, err := common.Marshal(request)
|
chatJSON, err := common.Marshal(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||||
@@ -82,9 +81,9 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
chatJSON, err = relaycommon.ApplyParamOverride(chatJSON, info.ParamOverride, overrideCtx)
|
chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return nil, newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
|
|
||||||
// apply param override
|
// apply param override
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
)
|
)
|
||||||
@@ -775,6 +777,754 @@ func TestApplyParamOverrideToUpper(t *testing.T) {
|
|||||||
assertJSONEqual(t, `{"model":"GPT-4"}`, string(out))
|
assertJSONEqual(t, `{"model":"GPT-4"}`, string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideReturnError(t *testing.T) {
|
||||||
|
input := []byte(`{"model":"gemini-2.5-pro"}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "return_error",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"message": "forced bad request by param override",
|
||||||
|
"status_code": 422,
|
||||||
|
"code": "forced_bad_request",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"skip_retry": true,
|
||||||
|
},
|
||||||
|
"conditions": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "retry.is_retry",
|
||||||
|
"mode": "full",
|
||||||
|
"value": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"retry": map[string]interface{}{
|
||||||
|
"index": 1,
|
||||||
|
"is_retry": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
returnErr, ok := AsParamOverrideReturnError(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected ParamOverrideReturnError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if returnErr.StatusCode != 422 {
|
||||||
|
t.Fatalf("expected status 422, got %d", returnErr.StatusCode)
|
||||||
|
}
|
||||||
|
if returnErr.Code != "forced_bad_request" {
|
||||||
|
t.Fatalf("expected code forced_bad_request, got %s", returnErr.Code)
|
||||||
|
}
|
||||||
|
if !returnErr.SkipRetry {
|
||||||
|
t.Fatalf("expected skip_retry true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverridePruneObjectsByTypeString(t *testing.T) {
|
||||||
|
input := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":[
|
||||||
|
{"type":"output_text","text":"a"},
|
||||||
|
{"type":"redacted_thinking","text":"secret"},
|
||||||
|
{"type":"tool_call","name":"tool_a"}
|
||||||
|
]},
|
||||||
|
{"role":"assistant","content":[
|
||||||
|
{"type":"output_text","text":"b"},
|
||||||
|
{"type":"wrapper","parts":[
|
||||||
|
{"type":"redacted_thinking","text":"secret2"},
|
||||||
|
{"type":"output_text","text":"c"}
|
||||||
|
]}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "prune_objects",
|
||||||
|
"value": "redacted_thinking",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":[
|
||||||
|
{"type":"output_text","text":"a"},
|
||||||
|
{"type":"tool_call","name":"tool_a"}
|
||||||
|
]},
|
||||||
|
{"role":"assistant","content":[
|
||||||
|
{"type":"output_text","text":"b"},
|
||||||
|
{"type":"wrapper","parts":[
|
||||||
|
{"type":"output_text","text":"c"}
|
||||||
|
]}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverridePruneObjectsWhereAndPath(t *testing.T) {
|
||||||
|
input := []byte(`{
|
||||||
|
"a":{"items":[{"type":"redacted_thinking","id":1},{"type":"output_text","id":2}]},
|
||||||
|
"b":{"items":[{"type":"redacted_thinking","id":3},{"type":"output_text","id":4}]}
|
||||||
|
}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "a",
|
||||||
|
"mode": "prune_objects",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"where": map[string]interface{}{
|
||||||
|
"type": "redacted_thinking",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{
|
||||||
|
"a":{"items":[{"type":"output_text","id":2}]},
|
||||||
|
"b":{"items":[{"type":"redacted_thinking","id":3},{"type":"output_text","id":4}]}
|
||||||
|
}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideNormalizeThinkingSignatureUnsupported(t *testing.T) {
|
||||||
|
input := []byte(`{"items":[{"type":"redacted_thinking"}]}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "normalize_thinking_signature",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideConditionFromRetryAndLastErrorContext(t *testing.T) {
|
||||||
|
info := &RelayInfo{
|
||||||
|
RetryIndex: 1,
|
||||||
|
LastError: types.WithOpenAIError(types.OpenAIError{
|
||||||
|
Message: "invalid thinking signature",
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
Code: "bad_thought_signature",
|
||||||
|
}, 400),
|
||||||
|
}
|
||||||
|
ctx := BuildParamOverrideContext(info)
|
||||||
|
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "temperature",
|
||||||
|
"mode": "set",
|
||||||
|
"value": 0.1,
|
||||||
|
"logic": "AND",
|
||||||
|
"conditions": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "is_retry",
|
||||||
|
"mode": "full",
|
||||||
|
"value": true,
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "last_error.code",
|
||||||
|
"mode": "contains",
|
||||||
|
"value": "thought_signature",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.1}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideConditionFromRequestHeaders(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "temperature",
|
||||||
|
"mode": "set",
|
||||||
|
"value": 0.1,
|
||||||
|
"conditions": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "request_headers.authorization",
|
||||||
|
"mode": "contains",
|
||||||
|
"value": "Bearer ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"request_headers": map[string]interface{}{
|
||||||
|
"authorization": "Bearer token-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.1}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetHeaderAndUseInLaterCondition(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "X-Debug-Mode",
|
||||||
|
"value": "enabled",
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "temperature",
|
||||||
|
"mode": "set",
|
||||||
|
"value": 0.1,
|
||||||
|
"conditions": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "header_override.x-debug-mode",
|
||||||
|
"mode": "full",
|
||||||
|
"value": "enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.1}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideCopyHeaderFromRequestHeaders(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "copy_header",
|
||||||
|
"from": "Authorization",
|
||||||
|
"to": "X-Upstream-Auth",
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "temperature",
|
||||||
|
"mode": "set",
|
||||||
|
"value": 0.1,
|
||||||
|
"conditions": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "header_override.x-upstream-auth",
|
||||||
|
"mode": "contains",
|
||||||
|
"value": "Bearer ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"request_headers": map[string]interface{}{
|
||||||
|
"authorization": "Bearer token-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.1}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverridePassHeadersSkipsMissingHeaders(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "pass_headers",
|
||||||
|
"value": []interface{}{"X-Codex-Beta-Features", "Session_id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"request_headers": map[string]interface{}{
|
||||||
|
"session_id": "sess-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if headers["session_id"] != "sess-123" {
|
||||||
|
t.Fatalf("expected session_id to be passed, got: %v", headers["session_id"])
|
||||||
|
}
|
||||||
|
if _, exists := headers["x-codex-beta-features"]; exists {
|
||||||
|
t.Fatalf("expected missing header to be skipped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideCopyHeaderSkipsMissingSource(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "copy_header",
|
||||||
|
"from": "X-Missing-Header",
|
||||||
|
"to": "X-Upstream-Auth",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"request_headers": map[string]interface{}{
|
||||||
|
"authorization": "Bearer token-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := headers["x-upstream-auth"]; exists {
|
||||||
|
t.Fatalf("expected X-Upstream-Auth to be skipped when source header is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideMoveHeaderSkipsMissingSource(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "move_header",
|
||||||
|
"from": "X-Missing-Header",
|
||||||
|
"to": "X-Upstream-Auth",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"request_headers": map[string]interface{}{
|
||||||
|
"authorization": "Bearer token-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := headers["x-upstream-auth"]; exists {
|
||||||
|
t.Fatalf("expected X-Upstream-Auth to be skipped when source header is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSyncFieldsHeaderToJSON(t *testing.T) {
|
||||||
|
input := []byte(`{"model":"gpt-4"}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "sync_fields",
|
||||||
|
"from": "header:session_id",
|
||||||
|
"to": "json:prompt_cache_key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"request_headers": map[string]interface{}{
|
||||||
|
"session_id": "sess-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"sess-123"}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSyncFieldsJSONToHeader(t *testing.T) {
|
||||||
|
input := []byte(`{"model":"gpt-4","prompt_cache_key":"cache-abc"}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "sync_fields",
|
||||||
|
"from": "header:session_id",
|
||||||
|
"to": "json:prompt_cache_key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"cache-abc"}`, string(out))
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if headers["session_id"] != "cache-abc" {
|
||||||
|
t.Fatalf("expected session_id to be synced from prompt_cache_key, got: %v", headers["session_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSyncFieldsNoChangeWhenBothExist(t *testing.T) {
|
||||||
|
input := []byte(`{"model":"gpt-4","prompt_cache_key":"cache-body"}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "sync_fields",
|
||||||
|
"from": "header:session_id",
|
||||||
|
"to": "json:prompt_cache_key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"request_headers": map[string]interface{}{
|
||||||
|
"session_id": "cache-header",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"cache-body"}`, string(out))
|
||||||
|
|
||||||
|
headers, _ := ctx["header_override"].(map[string]interface{})
|
||||||
|
if headers != nil {
|
||||||
|
if _, exists := headers["session_id"]; exists {
|
||||||
|
t.Fatalf("expected no override when both sides already have value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSyncFieldsInvalidTarget(t *testing.T) {
|
||||||
|
input := []byte(`{"model":"gpt-4"}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "sync_fields",
|
||||||
|
"from": "foo:session_id",
|
||||||
|
"to": "json:prompt_cache_key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ApplyParamOverride(input, override, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetHeaderKeepOrigin(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "X-Feature-Flag",
|
||||||
|
"value": "new-value",
|
||||||
|
"keep_origin": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"header_override": map[string]interface{}{
|
||||||
|
"x-feature-flag": "legacy-value",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if headers["x-feature-flag"] != "legacy-value" {
|
||||||
|
t.Fatalf("expected keep_origin to preserve old value, got: %v", headers["x-feature-flag"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetHeaderMapRewritesCommaSeparatedHeader(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "anthropic-beta",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"advanced-tool-use-2025-11-20": nil,
|
||||||
|
"computer-use-2025-01-24": "computer-use-2025-01-24",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"request_headers": map[string]interface{}{
|
||||||
|
"anthropic-beta": "advanced-tool-use-2025-11-20, computer-use-2025-01-24",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if headers["anthropic-beta"] != "computer-use-2025-01-24" {
|
||||||
|
t.Fatalf("expected anthropic-beta to keep only mapped value, got: %v", headers["anthropic-beta"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideSetHeaderMapDeleteWholeHeaderWhenAllTokensCleared(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "anthropic-beta",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"advanced-tool-use-2025-11-20": nil,
|
||||||
|
"computer-use-2025-01-24": nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"header_override": map[string]interface{}{
|
||||||
|
"anthropic-beta": "advanced-tool-use-2025-11-20,computer-use-2025-01-24",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers, ok := ctx["header_override"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected header_override context map")
|
||||||
|
}
|
||||||
|
if _, exists := headers["anthropic-beta"]; exists {
|
||||||
|
t.Fatalf("expected anthropic-beta to be deleted when all mapped values are null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideConditionsObjectShorthand(t *testing.T) {
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
override := map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"path": "temperature",
|
||||||
|
"mode": "set",
|
||||||
|
"value": 0.1,
|
||||||
|
"logic": "AND",
|
||||||
|
"conditions": map[string]interface{}{
|
||||||
|
"is_retry": true,
|
||||||
|
"last_error.status_code": 400.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := map[string]interface{}{
|
||||||
|
"is_retry": true,
|
||||||
|
"last_error": map[string]interface{}{
|
||||||
|
"status_code": 400.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ApplyParamOverride(input, override, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.1}`, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideWithRelayInfoSyncRuntimeHeaders(t *testing.T) {
|
||||||
|
info := &RelayInfo{
|
||||||
|
ChannelMeta: &ChannelMeta{
|
||||||
|
ParamOverride: map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "X-Injected-By-Param-Override",
|
||||||
|
"value": "enabled",
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "delete_header",
|
||||||
|
"path": "X-Delete-Me",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HeadersOverride: map[string]interface{}{
|
||||||
|
"X-Delete-Me": "legacy",
|
||||||
|
"X-Keep-Me": "keep",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
out, err := ApplyParamOverrideWithRelayInfo(input, info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
|
||||||
|
|
||||||
|
if !info.UseRuntimeHeadersOverride {
|
||||||
|
t.Fatalf("expected runtime header override to be enabled")
|
||||||
|
}
|
||||||
|
if info.RuntimeHeadersOverride["x-keep-me"] != "keep" {
|
||||||
|
t.Fatalf("expected x-keep-me header to be preserved, got: %v", info.RuntimeHeadersOverride["x-keep-me"])
|
||||||
|
}
|
||||||
|
if info.RuntimeHeadersOverride["x-injected-by-param-override"] != "enabled" {
|
||||||
|
t.Fatalf("expected x-injected-by-param-override header to be set, got: %v", info.RuntimeHeadersOverride["x-injected-by-param-override"])
|
||||||
|
}
|
||||||
|
if _, exists := info.RuntimeHeadersOverride["x-delete-me"]; exists {
|
||||||
|
t.Fatalf("expected x-delete-me header to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideWithRelayInfoMoveAndCopyHeaders(t *testing.T) {
|
||||||
|
info := &RelayInfo{
|
||||||
|
ChannelMeta: &ChannelMeta{
|
||||||
|
ParamOverride: map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "move_header",
|
||||||
|
"from": "X-Legacy-Trace",
|
||||||
|
"to": "X-Trace",
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "copy_header",
|
||||||
|
"from": "X-Trace",
|
||||||
|
"to": "X-Trace-Backup",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HeadersOverride: map[string]interface{}{
|
||||||
|
"X-Legacy-Trace": "trace-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := []byte(`{"temperature":0.7}`)
|
||||||
|
_, err := ApplyParamOverrideWithRelayInfo(input, info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, exists := info.RuntimeHeadersOverride["x-legacy-trace"]; exists {
|
||||||
|
t.Fatalf("expected source header to be removed after move")
|
||||||
|
}
|
||||||
|
if info.RuntimeHeadersOverride["x-trace"] != "trace-123" {
|
||||||
|
t.Fatalf("expected x-trace to be set, got: %v", info.RuntimeHeadersOverride["x-trace"])
|
||||||
|
}
|
||||||
|
if info.RuntimeHeadersOverride["x-trace-backup"] != "trace-123" {
|
||||||
|
t.Fatalf("expected x-trace-backup to be copied, got: %v", info.RuntimeHeadersOverride["x-trace-backup"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyParamOverrideWithRelayInfoSetHeaderMapRewritesAnthropicBeta(t *testing.T) {
|
||||||
|
info := &RelayInfo{
|
||||||
|
ChannelMeta: &ChannelMeta{
|
||||||
|
ParamOverride: map[string]interface{}{
|
||||||
|
"operations": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "set_header",
|
||||||
|
"path": "anthropic-beta",
|
||||||
|
"value": map[string]interface{}{
|
||||||
|
"advanced-tool-use-2025-11-20": nil,
|
||||||
|
"computer-use-2025-01-24": "computer-use-2025-01-24",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HeadersOverride: map[string]interface{}{
|
||||||
|
"anthropic-beta": "advanced-tool-use-2025-11-20, computer-use-2025-01-24",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ApplyParamOverrideWithRelayInfo([]byte(`{"temperature":0.7}`), info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.UseRuntimeHeadersOverride {
|
||||||
|
t.Fatalf("expected runtime header override to be enabled")
|
||||||
|
}
|
||||||
|
if info.RuntimeHeadersOverride["anthropic-beta"] != "computer-use-2025-01-24" {
|
||||||
|
t.Fatalf("expected anthropic-beta to be rewritten, got: %v", info.RuntimeHeadersOverride["anthropic-beta"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEffectiveHeaderOverrideUsesRuntimeOverrideAsFinalResult(t *testing.T) {
|
||||||
|
info := &RelayInfo{
|
||||||
|
UseRuntimeHeadersOverride: true,
|
||||||
|
RuntimeHeadersOverride: map[string]interface{}{
|
||||||
|
"x-runtime": "runtime-only",
|
||||||
|
},
|
||||||
|
ChannelMeta: &ChannelMeta{
|
||||||
|
HeadersOverride: map[string]interface{}{
|
||||||
|
"X-Static": "static-value",
|
||||||
|
"X-Deleted": "should-not-exist",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
effective := GetEffectiveHeaderOverride(info)
|
||||||
|
if effective["x-runtime"] != "runtime-only" {
|
||||||
|
t.Fatalf("expected x-runtime from runtime override, got: %v", effective["x-runtime"])
|
||||||
|
}
|
||||||
|
if _, exists := effective["x-static"]; exists {
|
||||||
|
t.Fatalf("expected runtime override to be final and not merge channel headers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoveDisabledFieldsSkipWhenChannelPassThroughEnabled(t *testing.T) {
|
func TestRemoveDisabledFieldsSkipWhenChannelPassThroughEnabled(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"service_tier":"flex",
|
"service_tier":"flex",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ type RelayInfo struct {
|
|||||||
RelayMode int
|
RelayMode int
|
||||||
OriginModelName string
|
OriginModelName string
|
||||||
RequestURLPath string
|
RequestURLPath string
|
||||||
|
RequestHeaders map[string]string
|
||||||
ShouldIncludeUsage bool
|
ShouldIncludeUsage bool
|
||||||
DisablePing bool // 是否禁止向下游发送自定义 Ping
|
DisablePing bool // 是否禁止向下游发送自定义 Ping
|
||||||
ClientWs *websocket.Conn
|
ClientWs *websocket.Conn
|
||||||
@@ -144,6 +145,10 @@ type RelayInfo struct {
|
|||||||
SubscriptionAmountUsedAfterPreConsume int64
|
SubscriptionAmountUsedAfterPreConsume int64
|
||||||
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
||||||
IsChannelTest bool // channel test request
|
IsChannelTest bool // channel test request
|
||||||
|
RetryIndex int
|
||||||
|
LastError *types.NewAPIError
|
||||||
|
RuntimeHeadersOverride map[string]interface{}
|
||||||
|
UseRuntimeHeadersOverride bool
|
||||||
|
|
||||||
PriceData types.PriceData
|
PriceData types.PriceData
|
||||||
|
|
||||||
@@ -461,6 +466,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
|||||||
isFirstResponse: true,
|
isFirstResponse: true,
|
||||||
RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path),
|
RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path),
|
||||||
RequestURLPath: c.Request.URL.String(),
|
RequestURLPath: c.Request.URL.String(),
|
||||||
|
RequestHeaders: cloneRequestHeaders(c),
|
||||||
IsStream: isStream,
|
IsStream: isStream,
|
||||||
|
|
||||||
StartTime: startTime,
|
StartTime: startTime,
|
||||||
@@ -493,6 +499,27 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneRequestHeaders(c *gin.Context) map[string]string {
|
||||||
|
if c == nil || c.Request == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(c.Request.Header) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
headers := make(map[string]string, len(c.Request.Header))
|
||||||
|
for key := range c.Request.Header {
|
||||||
|
value := strings.TrimSpace(c.Request.Header.Get(key))
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headers[key] = value
|
||||||
|
}
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) {
|
func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) {
|
||||||
var info *RelayInfo
|
var info *RelayInfo
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -172,9 +172,9 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
|||||||
|
|
||||||
// apply param override
|
// apply param override
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package relay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -46,15 +45,15 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
|||||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||||
}
|
}
|
||||||
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
|
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
|
||||||
jsonData, err := json.Marshal(convertedRequest)
|
jsonData, err := common.Marshal(convertedRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,9 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
|
|
||||||
// apply param override
|
// apply param override
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,14 +257,9 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
|
|||||||
|
|
||||||
// apply param override
|
// apply param override
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
reqMap := make(map[string]interface{})
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
_ = common.Unmarshal(jsonData, &reqMap)
|
|
||||||
for key, value := range info.ParamOverride {
|
|
||||||
reqMap[key] = value
|
|
||||||
}
|
|
||||||
jsonData, err = common.Marshal(reqMap)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.LogDebug(c, "Gemini embedding request body: "+string(jsonData))
|
logger.LogDebug(c, "Gemini embedding request body: "+string(jsonData))
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
|||||||
|
|
||||||
// apply param override
|
// apply param override
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
relay/param_override_error.go
Normal file
13
relay/param_override_error.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAPIErrorFromParamOverride(err error) *types.NewAPIError {
|
||||||
|
if fixedErr, ok := relaycommon.AsParamOverrideReturnError(err); ok {
|
||||||
|
return relaycommon.NewAPIErrorFromParamOverride(fixedErr)
|
||||||
|
}
|
||||||
|
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
||||||
|
}
|
||||||
@@ -61,9 +61,9 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
|
|
||||||
// apply param override
|
// apply param override
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
|||||||
|
|
||||||
// apply param override
|
// apply param override
|
||||||
if len(info.ParamOverride) > 0 {
|
if len(info.ParamOverride) > 0 {
|
||||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return newAPIErrorFromParamOverride(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
|||||||
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
|
|||||||
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),
|
||||||
|
|||||||
145
service/channel_affinity_template_test.go
Normal file
145
service/channel_affinity_template_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
|
"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"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
setting := operation_setting.GetChannelAffinitySetting()
|
||||||
|
require.NotNil(t, setting)
|
||||||
|
|
||||||
|
var codexRule *operation_setting.ChannelAffinityRule
|
||||||
|
for i := range setting.Rules {
|
||||||
|
rule := &setting.Rules[i]
|
||||||
|
if strings.EqualFold(strings.TrimSpace(rule.Name), "codex cli trace") {
|
||||||
|
codexRule = rule
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, codexRule)
|
||||||
|
|
||||||
|
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
|
||||||
|
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
|
||||||
|
|
||||||
|
cache := getChannelAffinityCache()
|
||||||
|
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(fmt.Sprintf(`{"prompt_cache_key":"%s"}`, affinityValue)))
|
||||||
|
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5", "default")
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, 9527, channelID)
|
||||||
|
|
||||||
|
baseOverride := map[string]interface{}{
|
||||||
|
"temperature": 0.2,
|
||||||
|
}
|
||||||
|
mergedOverride, applied := ApplyChannelAffinityOverrideTemplate(ctx, baseOverride)
|
||||||
|
require.True(t, applied)
|
||||||
|
require.Equal(t, 0.2, mergedOverride["temperature"])
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"Originator": "Codex CLI",
|
||||||
|
"Session_id": "sess-123",
|
||||||
|
"User-Agent": "codex-cli-test",
|
||||||
|
},
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
ParamOverride: mergedOverride,
|
||||||
|
HeadersOverride: map[string]interface{}{
|
||||||
|
"X-Static": "legacy-static",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := relaycommon.ApplyParamOverrideWithRelayInfo([]byte(`{"model":"gpt-5"}`), info)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, info.UseRuntimeHeadersOverride)
|
||||||
|
|
||||||
|
require.Equal(t, "legacy-static", info.RuntimeHeadersOverride["x-static"])
|
||||||
|
require.Equal(t, "Codex CLI", info.RuntimeHeadersOverride["originator"])
|
||||||
|
require.Equal(t, "sess-123", info.RuntimeHeadersOverride["session_id"])
|
||||||
|
require.Equal(t, "codex-cli-test", info.RuntimeHeadersOverride["user-agent"])
|
||||||
|
|
||||||
|
_, exists := info.RuntimeHeadersOverride["x-codex-beta-features"]
|
||||||
|
require.False(t, exists)
|
||||||
|
_, exists = info.RuntimeHeadersOverride["x-codex-turn-metadata"]
|
||||||
|
require.False(t, exists)
|
||||||
|
}
|
||||||
@@ -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 {
|
|||||||
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,32 +79,34 @@ 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{
|
||||||
{Type: "gjson", Path: "prompt_cache_key"},
|
{Type: "gjson", Path: "prompt_cache_key"},
|
||||||
},
|
},
|
||||||
ValueRegex: "",
|
ValueRegex: "",
|
||||||
TTLSeconds: 0,
|
TTLSeconds: 0,
|
||||||
SkipRetryOnFailure: false,
|
ParamOverrideTemplate: buildPassHeaderTemplate(codexCliPassThroughHeaders),
|
||||||
IncludeUsingGroup: true,
|
SkipRetryOnFailure: false,
|
||||||
IncludeRuleName: true,
|
IncludeUsingGroup: true,
|
||||||
UserAgentInclude: nil,
|
IncludeRuleName: true,
|
||||||
|
UserAgentInclude: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "claude code trace",
|
Name: "claude cli trace",
|
||||||
ModelRegex: []string{"^claude-.*$"},
|
ModelRegex: []string{"^claude-.*$"},
|
||||||
PathRegex: []string{"/v1/messages"},
|
PathRegex: []string{"/v1/messages"},
|
||||||
KeySources: []ChannelAffinityKeySource{
|
KeySources: []ChannelAffinityKeySource{
|
||||||
{Type: "gjson", Path: "metadata.user_id"},
|
{Type: "gjson", Path: "metadata.user_id"},
|
||||||
},
|
},
|
||||||
ValueRegex: "",
|
ValueRegex: "",
|
||||||
TTLSeconds: 0,
|
TTLSeconds: 0,
|
||||||
SkipRetryOnFailure: false,
|
ParamOverrideTemplate: buildPassHeaderTemplate(claudeCliPassThroughHeaders),
|
||||||
IncludeUsingGroup: true,
|
SkipRetryOnFailure: false,
|
||||||
IncludeRuleName: true,
|
IncludeUsingGroup: true,
|
||||||
UserAgentInclude: nil,
|
IncludeRuleName: true,
|
||||||
|
UserAgentInclude: nil,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
2
web/package.json
vendored
2
web/package.json
vendored
@@ -10,7 +10,7 @@
|
|||||||
"@visactor/react-vchart": "~1.8.8",
|
"@visactor/react-vchart": "~1.8.8",
|
||||||
"@visactor/vchart": "~1.8.8",
|
"@visactor/vchart": "~1.8.8",
|
||||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||||
"axios": "1.13.5",
|
"axios": "1.12.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import ModelSelectModal from './ModelSelectModal';
|
|||||||
import SingleModelSelectModal from './SingleModelSelectModal';
|
import SingleModelSelectModal from './SingleModelSelectModal';
|
||||||
import OllamaModelModal from './OllamaModelModal';
|
import OllamaModelModal from './OllamaModelModal';
|
||||||
import CodexOAuthModal from './CodexOAuthModal';
|
import CodexOAuthModal from './CodexOAuthModal';
|
||||||
|
import ParamOverrideEditorModal from './ParamOverrideEditorModal';
|
||||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||||
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||||
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
|
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
|
||||||
@@ -75,6 +76,7 @@ import {
|
|||||||
IconServer,
|
IconServer,
|
||||||
IconSetting,
|
IconSetting,
|
||||||
IconCode,
|
IconCode,
|
||||||
|
IconCopy,
|
||||||
IconGlobe,
|
IconGlobe,
|
||||||
IconBolt,
|
IconBolt,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
@@ -99,6 +101,28 @@ const REGION_EXAMPLE = {
|
|||||||
'claude-3-5-sonnet-20240620': 'europe-west1',
|
'claude-3-5-sonnet-20240620': 'europe-west1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PARAM_OVERRIDE_LEGACY_TEMPLATE = {
|
||||||
|
temperature: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARAM_OVERRIDE_OPERATIONS_TEMPLATE = {
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
path: 'temperature',
|
||||||
|
mode: 'set',
|
||||||
|
value: 0.7,
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
path: 'model',
|
||||||
|
mode: 'prefix',
|
||||||
|
value: 'openai/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
logic: 'AND',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// 支持并且已适配通过接口获取模型列表的渠道类型
|
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||||
const MODEL_FETCHABLE_TYPES = new Set([
|
const MODEL_FETCHABLE_TYPES = new Set([
|
||||||
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
|
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
|
||||||
@@ -148,6 +172,7 @@ const EditChannelModal = (props) => {
|
|||||||
base_url: '',
|
base_url: '',
|
||||||
other: '',
|
other: '',
|
||||||
model_mapping: '',
|
model_mapping: '',
|
||||||
|
param_override: '',
|
||||||
status_code_mapping: '',
|
status_code_mapping: '',
|
||||||
models: [],
|
models: [],
|
||||||
auto_ban: 1,
|
auto_ban: 1,
|
||||||
@@ -251,11 +276,69 @@ const EditChannelModal = (props) => {
|
|||||||
name: keyword,
|
name: keyword,
|
||||||
});
|
});
|
||||||
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
||||||
|
const paramOverrideMeta = useMemo(() => {
|
||||||
|
const raw =
|
||||||
|
typeof inputs.param_override === 'string'
|
||||||
|
? inputs.param_override.trim()
|
||||||
|
: '';
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
tagLabel: t('不更改'),
|
||||||
|
tagColor: 'grey',
|
||||||
|
preview: t(
|
||||||
|
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!verifyJSON(raw)) {
|
||||||
|
return {
|
||||||
|
tagLabel: t('JSON格式错误'),
|
||||||
|
tagColor: 'red',
|
||||||
|
preview: raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const pretty = JSON.stringify(parsed, null, 2);
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
!Array.isArray(parsed) &&
|
||||||
|
Array.isArray(parsed.operations)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
tagLabel: `${t('新格式模板')} (${parsed.operations.length})`,
|
||||||
|
tagColor: 'cyan',
|
||||||
|
preview: pretty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
tagLabel: `${t('旧格式模板')} (${Object.keys(parsed).length})`,
|
||||||
|
tagColor: 'blue',
|
||||||
|
preview: pretty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tagLabel: t('自定义 JSON'),
|
||||||
|
tagColor: 'orange',
|
||||||
|
preview: pretty,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
tagLabel: t('JSON格式错误'),
|
||||||
|
tagColor: 'red',
|
||||||
|
preview: raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [inputs.param_override, t]);
|
||||||
const [isIonetChannel, setIsIonetChannel] = useState(false);
|
const [isIonetChannel, setIsIonetChannel] = useState(false);
|
||||||
const [ionetMetadata, setIonetMetadata] = useState(null);
|
const [ionetMetadata, setIonetMetadata] = useState(null);
|
||||||
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
|
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
|
||||||
const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
|
const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [paramOverrideEditorVisible, setParamOverrideEditorVisible] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// 密钥显示状态
|
// 密钥显示状态
|
||||||
const [keyDisplayState, setKeyDisplayState] = useState({
|
const [keyDisplayState, setKeyDisplayState] = useState({
|
||||||
@@ -582,6 +665,100 @@ const EditChannelModal = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyParamOverrideJson = async () => {
|
||||||
|
const raw =
|
||||||
|
typeof inputs.param_override === 'string'
|
||||||
|
? inputs.param_override.trim()
|
||||||
|
: '';
|
||||||
|
if (!raw) {
|
||||||
|
showInfo(t('暂无可复制 JSON'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = raw;
|
||||||
|
if (verifyJSON(raw)) {
|
||||||
|
try {
|
||||||
|
content = JSON.stringify(JSON.parse(raw), null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
content = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await copy(content);
|
||||||
|
if (ok) {
|
||||||
|
showSuccess(t('参数覆盖 JSON 已复制'));
|
||||||
|
} else {
|
||||||
|
showError(t('复制失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseParamOverrideInput = () => {
|
||||||
|
const raw =
|
||||||
|
typeof inputs.param_override === 'string'
|
||||||
|
? inputs.param_override.trim()
|
||||||
|
: '';
|
||||||
|
if (!raw) return null;
|
||||||
|
if (!verifyJSON(raw)) {
|
||||||
|
throw new Error(t('当前参数覆盖不是合法的 JSON'));
|
||||||
|
}
|
||||||
|
return JSON.parse(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyParamOverrideTemplate = (
|
||||||
|
templateType = 'operations',
|
||||||
|
applyMode = 'fill',
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const parsedCurrent = parseParamOverrideInput();
|
||||||
|
if (templateType === 'legacy') {
|
||||||
|
if (applyMode === 'fill') {
|
||||||
|
handleInputChange(
|
||||||
|
'param_override',
|
||||||
|
JSON.stringify(PARAM_OVERRIDE_LEGACY_TEMPLATE, null, 2),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentLegacy =
|
||||||
|
parsedCurrent &&
|
||||||
|
typeof parsedCurrent === 'object' &&
|
||||||
|
!Array.isArray(parsedCurrent) &&
|
||||||
|
!Array.isArray(parsedCurrent.operations)
|
||||||
|
? parsedCurrent
|
||||||
|
: {};
|
||||||
|
const merged = {
|
||||||
|
...PARAM_OVERRIDE_LEGACY_TEMPLATE,
|
||||||
|
...currentLegacy,
|
||||||
|
};
|
||||||
|
handleInputChange('param_override', JSON.stringify(merged, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applyMode === 'fill') {
|
||||||
|
handleInputChange(
|
||||||
|
'param_override',
|
||||||
|
JSON.stringify(PARAM_OVERRIDE_OPERATIONS_TEMPLATE, null, 2),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentOperations =
|
||||||
|
parsedCurrent &&
|
||||||
|
typeof parsedCurrent === 'object' &&
|
||||||
|
!Array.isArray(parsedCurrent) &&
|
||||||
|
Array.isArray(parsedCurrent.operations)
|
||||||
|
? parsedCurrent.operations
|
||||||
|
: [];
|
||||||
|
const merged = {
|
||||||
|
operations: [
|
||||||
|
...currentOperations,
|
||||||
|
...PARAM_OVERRIDE_OPERATIONS_TEMPLATE.operations,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
handleInputChange('param_override', JSON.stringify(merged, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message || t('模板应用失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadChannel = async () => {
|
const loadChannel = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
let res = await API.get(`/api/channel/${channelId}`);
|
let res = await API.get(`/api/channel/${channelId}`);
|
||||||
@@ -1242,6 +1419,7 @@ const EditChannelModal = (props) => {
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
|
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
|
||||||
let localInputs = { ...formValues };
|
let localInputs = { ...formValues };
|
||||||
|
localInputs.param_override = inputs.param_override;
|
||||||
|
|
||||||
if (localInputs.type === 57) {
|
if (localInputs.type === 57) {
|
||||||
if (batch) {
|
if (batch) {
|
||||||
@@ -3150,78 +3328,73 @@ const EditChannelModal = (props) => {
|
|||||||
initValue={autoBan}
|
initValue={autoBan}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form.TextArea
|
<div className='mb-4'>
|
||||||
field='param_override'
|
<div className='flex items-center justify-between gap-2 mb-1'>
|
||||||
label={t('参数覆盖')}
|
<Text className='text-sm font-medium'>{t('参数覆盖')}</Text>
|
||||||
placeholder={
|
<Space wrap>
|
||||||
t(
|
<Button
|
||||||
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
|
size='small'
|
||||||
) +
|
type='primary'
|
||||||
'\n' +
|
icon={<IconCode size={14} />}
|
||||||
t('旧格式(直接覆盖):') +
|
onClick={() => setParamOverrideEditorVisible(true)}
|
||||||
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
>
|
||||||
'\n\n' +
|
{t('可视化编辑')}
|
||||||
t('新格式(支持条件判断与json自定义):') +
|
</Button>
|
||||||
'\n{\n "operations": [\n {\n "path": "temperature",\n "mode": "set",\n "value": 0.7,\n "conditions": [\n {\n "path": "model",\n "mode": "prefix",\n "value": "gpt"\n }\n ]\n }\n ]\n}'
|
<Button
|
||||||
}
|
size='small'
|
||||||
autosize
|
|
||||||
onChange={(value) =>
|
|
||||||
handleInputChange('param_override', value)
|
|
||||||
}
|
|
||||||
extraText={
|
|
||||||
<div className='flex gap-2 flex-wrap'>
|
|
||||||
<Text
|
|
||||||
className='!text-semi-color-primary cursor-pointer'
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleInputChange(
|
applyParamOverrideTemplate('operations', 'fill')
|
||||||
'param_override',
|
|
||||||
JSON.stringify({ temperature: 0 }, null, 2),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('旧格式模板')}
|
{t('填充新模板')}
|
||||||
</Text>
|
</Button>
|
||||||
<Text
|
<Button
|
||||||
className='!text-semi-color-primary cursor-pointer'
|
size='small'
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleInputChange(
|
applyParamOverrideTemplate('legacy', 'fill')
|
||||||
'param_override',
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
operations: [
|
|
||||||
{
|
|
||||||
path: 'temperature',
|
|
||||||
mode: 'set',
|
|
||||||
value: 0.7,
|
|
||||||
conditions: [
|
|
||||||
{
|
|
||||||
path: 'model',
|
|
||||||
mode: 'prefix',
|
|
||||||
value: 'gpt',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
logic: 'AND',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('新格式模板')}
|
{t('填充旧模板')}
|
||||||
</Text>
|
</Button>
|
||||||
<Text
|
</Space>
|
||||||
className='!text-semi-color-primary cursor-pointer'
|
</div>
|
||||||
onClick={() => formatJsonField('param_override')}
|
<Text type='tertiary' size='small'>
|
||||||
>
|
{t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数')}
|
||||||
{t('格式化')}
|
</Text>
|
||||||
</Text>
|
<div
|
||||||
|
className='mt-2 rounded-xl p-3'
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--semi-color-fill-0)',
|
||||||
|
border: '1px solid var(--semi-color-fill-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between mb-2'>
|
||||||
|
<Tag color={paramOverrideMeta.tagColor}>
|
||||||
|
{paramOverrideMeta.tagLabel}
|
||||||
|
</Tag>
|
||||||
|
<Space spacing={8}>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
icon={<IconCopy />}
|
||||||
|
type='tertiary'
|
||||||
|
onClick={copyParamOverrideJson}
|
||||||
|
>
|
||||||
|
{t('复制')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
type='tertiary'
|
||||||
|
onClick={() => setParamOverrideEditorVisible(true)}
|
||||||
|
>
|
||||||
|
{t('编辑')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
}
|
<pre className='mb-0 text-xs leading-5 whitespace-pre-wrap break-all max-h-56 overflow-auto'>
|
||||||
showClear
|
{paramOverrideMeta.preview}
|
||||||
/>
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
field='header_override'
|
field='header_override'
|
||||||
@@ -3641,6 +3814,16 @@ const EditChannelModal = (props) => {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ParamOverrideEditorModal
|
||||||
|
visible={paramOverrideEditorVisible}
|
||||||
|
value={inputs.param_override || ''}
|
||||||
|
onCancel={() => setParamOverrideEditorVisible(false)}
|
||||||
|
onSave={(nextValue) => {
|
||||||
|
handleInputChange('param_override', nextValue);
|
||||||
|
setParamOverrideEditorVisible(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ModelSelectModal
|
<ModelSelectModal
|
||||||
visible={modelModalVisible}
|
visible={modelModalVisible}
|
||||||
models={fetchedModels}
|
models={fetchedModels}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
90
web/src/constants/channel-affinity-template.constants.js
vendored
Normal file
90
web/src/constants/channel-affinity-template.constants.js
vendored
Normal file
@@ -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
web/src/constants/index.js
vendored
1
web/src/constants/index.js
vendored
@@ -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';
|
||||||
|
|||||||
@@ -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, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
@@ -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 {
|
|||||||
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 = [
|
|||||||
{ 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 = `[
|
|||||||
],
|
],
|
||||||
"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) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
.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) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
@@ -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 来源'),
|
title: t('Key 来源'),
|
||||||
dataIndex: 'key_sources',
|
dataIndex: 'key_sources',
|
||||||
@@ -450,6 +539,24 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
dataIndex: 'ttl_seconds',
|
dataIndex: 'ttl_seconds',
|
||||||
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) => {
|
||||||
@@ -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) {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
...(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) {
|
|||||||
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) {
|
|||||||
{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) {
|
|||||||
setEditingRule(null);
|
setEditingRule(null);
|
||||||
setModalInitValues(null);
|
setModalInitValues(null);
|
||||||
setModalAdvancedActiveKey([]);
|
setModalAdvancedActiveKey([]);
|
||||||
|
setParamTemplateDraft('');
|
||||||
|
setParamTemplateEditorVisible(false);
|
||||||
}}
|
}}
|
||||||
onOk={handleModalSave}
|
onOk={handleModalSave}
|
||||||
okText={t('保存')}
|
okText={t('保存')}
|
||||||
@@ -1032,6 +1159,76 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user