From ff76e75f4c1bbd10442f3397da7a1df3a4099ead Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 22 Feb 2026 00:10:49 +0800 Subject: [PATCH 01/13] feat: add retry-aware param override with return_error and prune_objects --- controller/channel-test.go | 9 +- controller/relay.go | 5 + relay/chat_completions_via_responses.go | 2 +- relay/claude_handler.go | 2 +- relay/common/override.go | 402 +++++++++++++++++++++++- relay/common/override_test.go | 184 +++++++++++ relay/common/relay_info.go | 2 + relay/compatible_handler.go | 2 +- relay/embedding_handler.go | 5 +- relay/gemini_handler.go | 11 +- relay/image_handler.go | 2 +- relay/param_override_error.go | 13 + relay/rerank_handler.go | 2 +- relay/responses_handler.go | 2 +- 14 files changed, 623 insertions(+), 20 deletions(-) create mode 100644 relay/param_override_error.go diff --git a/controller/channel-test.go b/controller/channel-test.go index ab12132b1..7ffee9fdf 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -366,7 +366,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string, newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed), } } - jsonData, err := json.Marshal(convertedRequest) + jsonData, err := common.Marshal(convertedRequest) if err != nil { return testResult{ context: c, @@ -387,6 +387,13 @@ func testChannel(channel *model.Channel, testModel string, endpointType string, if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) if err != nil { + if fixedErr, ok := relaycommon.AsParamOverrideReturnError(err); ok { + return testResult{ + context: c, + localErr: fixedErr, + newAPIError: relaycommon.NewAPIErrorFromParamOverride(fixedErr), + } + } return testResult{ context: c, localErr: err, diff --git a/controller/relay.go b/controller/relay.go index 0b30e6e9e..e3e92bc51 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -182,8 +182,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { ModelName: relayInfo.OriginModelName, Retry: common.GetPointer(0), } + relayInfo.RetryIndex = 0 + relayInfo.LastError = nil for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { + relayInfo.RetryIndex = retryParam.GetRetry() channel, channelErr := getChannel(c, relayInfo, retryParam) if channelErr != nil { logger.LogError(c, channelErr.Error()) @@ -216,10 +219,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } if newAPIError == nil { + relayInfo.LastError = nil return } 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) diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 38dae3c56..3f2fb1874 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -84,7 +84,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad if len(info.ParamOverride) > 0 { chatJSON, err = relaycommon.ApplyParamOverride(chatJSON, info.ParamOverride, overrideCtx) if err != nil { - return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + return nil, newAPIErrorFromParamOverride(err) } } diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 81adb276a..b9d9936e9 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -155,7 +155,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + return newAPIErrorFromParamOverride(err) } } diff --git a/relay/common/override.go b/relay/common/override.go index 1a0c2478d..070a8e7af 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -1,12 +1,15 @@ package common import ( + "errors" "fmt" + "net/http" "regexp" "strconv" "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -23,7 +26,7 @@ type ConditionOperation struct { type ParamOperation struct { Path string `json:"path"` - Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace + Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace, return_error, prune_objects Value interface{} `json:"value"` KeepOrigin bool `json:"keep_origin"` From string `json:"from,omitempty"` @@ -32,6 +35,76 @@ type ParamOperation struct { Logic string `json:"logic,omitempty"` // AND, OR (默认OR) } +type ParamOverrideReturnError struct { + Message string + StatusCode int + Code string + Type string + SkipRetry bool +} + +func (e *ParamOverrideReturnError) Error() string { + if e == nil { + return "param override return error" + } + if e.Message == "" { + return "param override return error" + } + return e.Message +} + +func AsParamOverrideReturnError(err error) (*ParamOverrideReturnError, bool) { + if err == nil { + return nil, false + } + var target *ParamOverrideReturnError + if errors.As(err, &target) { + return target, true + } + return nil, false +} + +func NewAPIErrorFromParamOverride(err *ParamOverrideReturnError) *types.NewAPIError { + if err == nil { + return types.NewError( + errors.New("param override return error is nil"), + types.ErrorCodeChannelParamOverrideInvalid, + types.ErrOptionWithSkipRetry(), + ) + } + + statusCode := err.StatusCode + if statusCode < http.StatusContinue || statusCode > http.StatusNetworkAuthenticationRequired { + statusCode = http.StatusBadRequest + } + + errorCode := err.Code + if strings.TrimSpace(errorCode) == "" { + errorCode = string(types.ErrorCodeInvalidRequest) + } + + errorType := err.Type + if strings.TrimSpace(errorType) == "" { + errorType = "invalid_request_error" + } + + message := strings.TrimSpace(err.Message) + if message == "" { + message = "request blocked by param override" + } + + opts := make([]types.NewAPIErrorOptions, 0, 1) + if err.SkipRetry { + opts = append(opts, types.ErrOptionWithSkipRetry()) + } + + return types.WithOpenAIError(types.OpenAIError{ + Message: message, + Type: errorType, + Code: errorCode, + }, statusCode, opts...) +} + func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, conditionContext map[string]interface{}) ([]byte, error) { if len(paramOverride) == 0 { return jsonData, nil @@ -372,16 +445,104 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte result, err = replaceStringValue(result, opPath, op.From, op.To) case "regex_replace": result, err = regexReplaceStringValue(result, opPath, op.From, op.To) + case "return_error": + returnErr, parseErr := parseParamOverrideReturnError(op.Value) + if parseErr != nil { + return "", parseErr + } + return "", returnErr + case "prune_objects": + result, err = pruneObjects(result, opPath, contextJSON, op.Value) default: return "", fmt.Errorf("unknown operation: %s", op.Mode) } if err != nil { - return "", fmt.Errorf("operation %s failed: %v", op.Mode, err) + return "", fmt.Errorf("operation %s failed: %w", op.Mode, err) } } return result, nil } +func parseParamOverrideReturnError(value interface{}) (*ParamOverrideReturnError, error) { + result := &ParamOverrideReturnError{ + StatusCode: http.StatusBadRequest, + Code: string(types.ErrorCodeInvalidRequest), + Type: "invalid_request_error", + SkipRetry: true, + } + + switch raw := value.(type) { + case nil: + return nil, fmt.Errorf("return_error value is required") + case string: + result.Message = strings.TrimSpace(raw) + case map[string]interface{}: + if message, ok := raw["message"].(string); ok { + result.Message = strings.TrimSpace(message) + } + if result.Message == "" { + if message, ok := raw["msg"].(string); ok { + result.Message = strings.TrimSpace(message) + } + } + + if code, exists := raw["code"]; exists { + codeStr := strings.TrimSpace(fmt.Sprintf("%v", code)) + if codeStr != "" { + result.Code = codeStr + } + } + if errType, ok := raw["type"].(string); ok { + errType = strings.TrimSpace(errType) + if errType != "" { + result.Type = errType + } + } + if skipRetry, ok := raw["skip_retry"].(bool); ok { + result.SkipRetry = skipRetry + } + + if statusCodeRaw, exists := raw["status_code"]; exists { + statusCode, ok := parseOverrideInt(statusCodeRaw) + if !ok { + return nil, fmt.Errorf("return_error status_code must be an integer") + } + result.StatusCode = statusCode + } else if statusRaw, exists := raw["status"]; exists { + statusCode, ok := parseOverrideInt(statusRaw) + if !ok { + return nil, fmt.Errorf("return_error status must be an integer") + } + result.StatusCode = statusCode + } + default: + return nil, fmt.Errorf("return_error value must be string or object") + } + + if result.Message == "" { + return nil, fmt.Errorf("return_error message is required") + } + if result.StatusCode < http.StatusContinue || result.StatusCode > http.StatusNetworkAuthenticationRequired { + return nil, fmt.Errorf("return_error status code out of range: %d", result.StatusCode) + } + + return result, nil +} + +func parseOverrideInt(v interface{}) (int, bool) { + switch value := v.(type) { + case int: + return value, true + case float64: + if value != float64(int(value)) { + return 0, false + } + return int(value), true + default: + return 0, false + } +} + func moveValue(jsonStr, fromPath, toPath string) (string, error) { sourceValue := gjson.Get(jsonStr, fromPath) if !sourceValue.Exists() { @@ -537,6 +698,217 @@ func regexReplaceStringValue(jsonStr, path, pattern, replacement string) (string return sjson.Set(jsonStr, path, re.ReplaceAllString(current.String(), replacement)) } +type pruneObjectsOptions struct { + conditions []ConditionOperation + logic string + recursive bool +} + +func pruneObjects(jsonStr, path, contextJSON string, value interface{}) (string, error) { + options, err := parsePruneObjectsOptions(value) + if err != nil { + return "", err + } + + if path == "" { + var root interface{} + if err := common.Unmarshal([]byte(jsonStr), &root); err != nil { + return "", err + } + cleaned, _, err := pruneObjectsNode(root, options, contextJSON, true) + if err != nil { + return "", err + } + cleanedBytes, err := common.Marshal(cleaned) + if err != nil { + return "", err + } + return string(cleanedBytes), nil + } + + target := gjson.Get(jsonStr, path) + if !target.Exists() { + return jsonStr, nil + } + + var targetNode interface{} + if target.Type == gjson.JSON { + if err := common.Unmarshal([]byte(target.Raw), &targetNode); err != nil { + return "", err + } + } else { + targetNode = target.Value() + } + + cleaned, _, err := pruneObjectsNode(targetNode, options, contextJSON, true) + if err != nil { + return "", err + } + cleanedBytes, err := common.Marshal(cleaned) + if err != nil { + return "", err + } + return sjson.SetRaw(jsonStr, path, string(cleanedBytes)) +} + +func parsePruneObjectsOptions(value interface{}) (pruneObjectsOptions, error) { + opts := pruneObjectsOptions{ + logic: "AND", + recursive: true, + } + + switch raw := value.(type) { + case nil: + return opts, fmt.Errorf("prune_objects value is required") + case string: + v := strings.TrimSpace(raw) + if v == "" { + return opts, fmt.Errorf("prune_objects value is required") + } + opts.conditions = []ConditionOperation{ + { + Path: "type", + Mode: "full", + Value: v, + }, + } + case map[string]interface{}: + if logic, ok := raw["logic"].(string); ok && strings.TrimSpace(logic) != "" { + opts.logic = logic + } + if recursive, ok := raw["recursive"].(bool); ok { + opts.recursive = recursive + } + + if condRaw, exists := raw["conditions"]; exists { + conditions, err := parseConditionOperations(condRaw) + if err != nil { + return opts, err + } + opts.conditions = append(opts.conditions, conditions...) + } + + if whereRaw, exists := raw["where"]; exists { + whereMap, ok := whereRaw.(map[string]interface{}) + if !ok { + return opts, fmt.Errorf("prune_objects where must be object") + } + for key, val := range whereMap { + key = strings.TrimSpace(key) + if key == "" { + continue + } + opts.conditions = append(opts.conditions, ConditionOperation{ + Path: key, + Mode: "full", + Value: val, + }) + } + } + + if matchType, exists := raw["type"]; exists { + opts.conditions = append(opts.conditions, ConditionOperation{ + Path: "type", + Mode: "full", + Value: matchType, + }) + } + default: + return opts, fmt.Errorf("prune_objects value must be string or object") + } + + if len(opts.conditions) == 0 { + return opts, fmt.Errorf("prune_objects conditions are required") + } + return opts, nil +} + +func parseConditionOperations(raw interface{}) ([]ConditionOperation, error) { + items, ok := raw.([]interface{}) + if !ok { + return nil, fmt.Errorf("conditions must be an array") + } + + result := make([]ConditionOperation, 0, len(items)) + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("condition must be object") + } + path, _ := itemMap["path"].(string) + mode, _ := itemMap["mode"].(string) + if strings.TrimSpace(path) == "" || strings.TrimSpace(mode) == "" { + return nil, fmt.Errorf("condition path/mode is required") + } + condition := ConditionOperation{ + Path: path, + Mode: mode, + } + if value, exists := itemMap["value"]; exists { + condition.Value = value + } + if invert, ok := itemMap["invert"].(bool); ok { + condition.Invert = invert + } + if passMissingKey, ok := itemMap["pass_missing_key"].(bool); ok { + condition.PassMissingKey = passMissingKey + } + result = append(result, condition) + } + return result, nil +} + +func pruneObjectsNode(node interface{}, options pruneObjectsOptions, contextJSON string, isRoot bool) (interface{}, bool, error) { + switch value := node.(type) { + case []interface{}: + result := make([]interface{}, 0, len(value)) + for _, item := range value { + next, drop, err := pruneObjectsNode(item, options, contextJSON, false) + if err != nil { + return nil, false, err + } + if drop { + continue + } + result = append(result, next) + } + return result, false, nil + case map[string]interface{}: + shouldDrop, err := shouldPruneObject(value, options, contextJSON) + if err != nil { + return nil, false, err + } + if shouldDrop && !isRoot { + return nil, true, nil + } + if !options.recursive { + return value, false, nil + } + for key, child := range value { + next, drop, err := pruneObjectsNode(child, options, contextJSON, false) + if err != nil { + return nil, false, err + } + if drop { + delete(value, key) + continue + } + value[key] = next + } + return value, false, nil + default: + return node, false, nil + } +} + +func shouldPruneObject(node map[string]interface{}, options pruneObjectsOptions, contextJSON string) (bool, error) { + nodeBytes, err := common.Marshal(node) + if err != nil { + return false, err + } + return checkConditions(string(nodeBytes), contextJSON, options.conditions, options.logic) +} + func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (string, error) { current := gjson.Get(jsonStr, path) var currentMap, newMap map[string]interface{} @@ -598,6 +970,32 @@ func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { } } + ctx["retry_index"] = info.RetryIndex + ctx["is_retry"] = info.RetryIndex > 0 + ctx["retry"] = map[string]interface{}{ + "index": info.RetryIndex, + "is_retry": info.RetryIndex > 0, + } + + if info.LastError != nil { + code := string(info.LastError.GetErrorCode()) + errorType := string(info.LastError.GetErrorType()) + lastError := map[string]interface{}{ + "status_code": info.LastError.StatusCode, + "message": info.LastError.Error(), + "code": code, + "error_code": code, + "type": errorType, + "error_type": errorType, + "skip_retry": types.IsSkipRetryError(info.LastError), + } + ctx["last_error"] = lastError + ctx["last_error_status_code"] = info.LastError.StatusCode + ctx["last_error_message"] = info.LastError.Error() + ctx["last_error_code"] = code + ctx["last_error_type"] = errorType + } + ctx["is_channel_test"] = info.IsChannelTest return ctx } diff --git a/relay/common/override_test.go b/relay/common/override_test.go index 021df3f60..cc1489f74 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "reflect" "testing" + + "github.com/QuantumNous/new-api/types" ) func TestApplyParamOverrideTrimPrefix(t *testing.T) { @@ -772,6 +774,188 @@ func TestApplyParamOverrideToUpper(t *testing.T) { 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 assertJSONEqual(t *testing.T, want, got string) { t.Helper() diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 81b7d21d6..c10e6d5fb 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -140,6 +140,8 @@ type RelayInfo struct { SubscriptionAmountUsedAfterPreConsume int64 IsClaudeBetaQuery bool // /v1/messages?beta=true IsChannelTest bool // channel test request + RetryIndex int + LastError *types.NewAPIError PriceData types.PriceData diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index e7adddbbf..4cf5e0411 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -174,7 +174,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + return newAPIErrorFromParamOverride(err) } } diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index 1a41756b8..edbd1f7e6 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -2,7 +2,6 @@ package relay import ( "bytes" - "encoding/json" "fmt" "net/http" @@ -46,7 +45,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) - jsonData, err := json.Marshal(convertedRequest) + jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } @@ -54,7 +53,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + return newAPIErrorFromParamOverride(err) } } diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index a1b8e592e..a58c404f5 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -159,7 +159,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) 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 if len(info.ParamOverride) > 0 { - reqMap := make(map[string]interface{}) - _ = common.Unmarshal(jsonData, &reqMap) - for key, value := range info.ParamOverride { - reqMap[key] = value - } - jsonData, err = common.Marshal(reqMap) + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + return newAPIErrorFromParamOverride(err) } } logger.LogDebug(c, "Gemini embedding request body: "+string(jsonData)) diff --git a/relay/image_handler.go b/relay/image_handler.go index e83294268..21a5be2fa 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -72,7 +72,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + return newAPIErrorFromParamOverride(err) } } diff --git a/relay/param_override_error.go b/relay/param_override_error.go new file mode 100644 index 000000000..c23382985 --- /dev/null +++ b/relay/param_override_error.go @@ -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()) +} diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 8fe2930e9..9c4bef6e1 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -63,7 +63,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + return newAPIErrorFromParamOverride(err) } } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 04fc3470e..2190be87f 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -98,7 +98,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + return newAPIErrorFromParamOverride(err) } } From 91b300f522afcfa76a753562565d69e31b4a073f Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 22 Feb 2026 00:45:49 +0800 Subject: [PATCH 02/13] feat: unify param/header overrides with retry-aware conditions and flexible header operations --- controller/channel-test.go | 2 +- relay/channel/api_request.go | 12 +- relay/channel/api_request_test.go | 31 ++ relay/chat_completions_via_responses.go | 3 +- relay/claude_handler.go | 2 +- relay/common/override.go | 488 ++++++++++++++++++++---- relay/common/override_test.go | 248 ++++++++++++ relay/common/relay_info.go | 25 ++ relay/compatible_handler.go | 2 +- relay/embedding_handler.go | 2 +- relay/gemini_handler.go | 4 +- relay/image_handler.go | 2 +- relay/rerank_handler.go | 2 +- relay/responses_handler.go | 2 +- 14 files changed, 738 insertions(+), 87 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 7ffee9fdf..3947c8d5c 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -385,7 +385,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string, //} 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 fixedErr, ok := relaycommon.AsParamOverrideReturnError(err); ok { return testResult{ diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index ec5573ab1..dcdff584b 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -168,11 +168,19 @@ func applyHeaderOverridePlaceholders(template string, c *gin.Context, apiKey str // 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) { headerOverride := make(map[string]string) + if info == nil { + return headerOverride, nil + } + + headerOverrideSource := info.HeadersOverride + if info.UseRuntimeHeadersOverride { + headerOverrideSource = info.RuntimeHeadersOverride + } passAll := false var passthroughRegex []*regexp.Regexp if !info.IsChannelTest { - for k := range info.HeadersOverride { + for k := range headerOverrideSource { key := strings.TrimSpace(k) if key == "" { continue @@ -232,7 +240,7 @@ func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]s } } - for k, v := range info.HeadersOverride { + for k, v := range headerOverrideSource { if isHeaderPassthroughRuleKey(k) { continue } diff --git a/relay/channel/api_request_test.go b/relay/channel/api_request_test.go index c55ffcab2..31e15340a 100644 --- a/relay/channel/api_request_test.go +++ b/relay/channel/api_request_test.go @@ -79,3 +79,34 @@ func TestProcessHeaderOverride_NonTestKeepsClientHeaderPlaceholder(t *testing.T) require.NoError(t, err) require.Equal(t, "trace-123", headers["X-Upstream-Trace"]) } + +func TestProcessHeaderOverride_RuntimeOverrideHasPriority(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"]) + _, ok := headers["X-Legacy"] + require.False(t, ok) +} diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 3f2fb1874..580cba5f4 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -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) { - overrideCtx := relaycommon.BuildParamOverrideContext(info) chatJSON, err := common.Marshal(request) if err != nil { return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) @@ -82,7 +81,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad } if len(info.ParamOverride) > 0 { - chatJSON, err = relaycommon.ApplyParamOverride(chatJSON, info.ParamOverride, overrideCtx) + chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info) if err != nil { return nil, newAPIErrorFromParamOverride(err) } diff --git a/relay/claude_handler.go b/relay/claude_handler.go index b9d9936e9..2dfa09df5 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -153,7 +153,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ // apply param override if len(info.ParamOverride) > 0 { - jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) if err != nil { return newAPIErrorFromParamOverride(err) } diff --git a/relay/common/override.go b/relay/common/override.go index 070a8e7af..9ac007ecd 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -10,12 +10,20 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/types" + "github.com/samber/lo" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) var negativeIndexRegexp = regexp.MustCompile(`\.(-\d+)`) +const ( + paramOverrideContextRequestHeaders = "request_headers" + paramOverrideContextRequestHeadersRaw = "request_headers_raw" + paramOverrideContextHeaderOverride = "header_override" + paramOverrideContextHeaderOverrideNormalized = "header_override_normalized" +) + type ConditionOperation struct { Path string `json:"path"` // JSON路径 Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte @@ -26,7 +34,7 @@ type ConditionOperation struct { type ParamOperation struct { Path string `json:"path"` - Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace, return_error, prune_objects + Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace, return_error, prune_objects, set_header, delete_header, copy_header, move_header Value interface{} `json:"value"` KeepOrigin bool `json:"keep_origin"` From string `json:"from,omitempty"` @@ -121,6 +129,35 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c return applyOperationsLegacy(jsonData, paramOverride) } +func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte, error) { + paramOverride := getParamOverrideMap(info) + if len(paramOverride) == 0 { + return jsonData, nil + } + + overrideCtx := BuildParamOverrideContext(info) + result, err := ApplyParamOverride(jsonData, paramOverride, overrideCtx) + if err != nil { + return nil, err + } + syncRuntimeHeaderOverrideFromContext(info, overrideCtx) + return result, nil +} + +func getParamOverrideMap(info *RelayInfo) map[string]interface{} { + if info == nil || info.ChannelMeta == nil { + return nil + } + return info.ChannelMeta.ParamOverride +} + +func getHeaderOverrideMap(info *RelayInfo) map[string]interface{} { + if info == nil || info.ChannelMeta == nil { + return nil + } + return info.ChannelMeta.HeadersOverride +} + func tryParseOperations(paramOverride map[string]interface{}) ([]ParamOperation, bool) { // 检查是否包含 "operations" 字段 if opsValue, exists := paramOverride["operations"]; exists { @@ -161,29 +198,11 @@ func tryParseOperations(paramOverride map[string]interface{}) ([]ParamOperation, // 解析条件 if conditions, exists := opMap["conditions"]; exists { - if condSlice, ok := conditions.([]interface{}); ok { - for _, cond := range condSlice { - if condMap, ok := cond.(map[string]interface{}); ok { - condition := ConditionOperation{} - if path, ok := condMap["path"].(string); ok { - condition.Path = path - } - if mode, ok := condMap["mode"].(string); ok { - condition.Mode = mode - } - if value, ok := condMap["value"]; ok { - condition.Value = value - } - if invert, ok := condMap["invert"].(bool); ok { - condition.Invert = invert - } - if passMissingKey, ok := condMap["pass_missing_key"].(bool); ok { - condition.PassMissingKey = passMissingKey - } - operation.Conditions = append(operation.Conditions, condition) - } - } + parsedConditions, err := parseConditionOperations(conditions) + if err != nil { + return nil, false } + operation.Conditions = append(operation.Conditions, parsedConditions...) } operations = append(operations, operation) @@ -212,20 +231,9 @@ func checkConditions(jsonStr, contextJSON string, conditions []ConditionOperatio } if strings.ToUpper(logic) == "AND" { - for _, result := range results { - if !result { - return false, nil - } - } - return true, nil - } else { - for _, result := range results { - if result { - return true, nil - } - } - return false, nil + return lo.EveryBy(results, func(item bool) bool { return item }), nil } + return lo.SomeBy(results, func(item bool) bool { return item }), nil } func checkSingleCondition(jsonStr, contextJSON string, condition ConditionOperation) (bool, error) { @@ -382,13 +390,10 @@ func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{} } func applyOperations(jsonStr string, operations []ParamOperation, conditionContext map[string]interface{}) (string, error) { - var contextJSON string - if conditionContext != nil && len(conditionContext) > 0 { - ctxBytes, err := common.Marshal(conditionContext) - if err != nil { - return "", fmt.Errorf("failed to marshal condition context: %v", err) - } - contextJSON = string(ctxBytes) + context := ensureContextMap(conditionContext) + contextJSON, err := marshalContextJSON(context) + if err != nil { + return "", fmt.Errorf("failed to marshal condition context: %v", err) } result := jsonStr @@ -453,6 +458,42 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte return "", returnErr case "prune_objects": result, err = pruneObjects(result, opPath, contextJSON, op.Value) + case "set_header": + err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin) + if err == nil { + contextJSON, err = marshalContextJSON(context) + } + case "delete_header": + err = deleteHeaderOverrideInContext(context, op.Path) + if err == nil { + contextJSON, err = marshalContextJSON(context) + } + case "copy_header": + sourceHeader := strings.TrimSpace(op.From) + targetHeader := strings.TrimSpace(op.To) + if sourceHeader == "" { + sourceHeader = strings.TrimSpace(op.Path) + } + if targetHeader == "" { + targetHeader = strings.TrimSpace(op.Path) + } + err = copyHeaderInContext(context, sourceHeader, targetHeader, op.KeepOrigin) + if err == nil { + contextJSON, err = marshalContextJSON(context) + } + case "move_header": + sourceHeader := strings.TrimSpace(op.From) + targetHeader := strings.TrimSpace(op.To) + if sourceHeader == "" { + sourceHeader = strings.TrimSpace(op.Path) + } + if targetHeader == "" { + targetHeader = strings.TrimSpace(op.Path) + } + err = moveHeaderInContext(context, sourceHeader, targetHeader, op.KeepOrigin) + if err == nil { + contextJSON, err = marshalContextJSON(context) + } default: return "", fmt.Errorf("unknown operation: %s", op.Mode) } @@ -543,6 +584,276 @@ func parseOverrideInt(v interface{}) (int, bool) { } } +func ensureContextMap(conditionContext map[string]interface{}) map[string]interface{} { + if conditionContext != nil { + return conditionContext + } + return make(map[string]interface{}) +} + +func marshalContextJSON(context map[string]interface{}) (string, error) { + if context == nil || len(context) == 0 { + return "", nil + } + ctxBytes, err := common.Marshal(context) + if err != nil { + return "", err + } + return string(ctxBytes), nil +} + +func setHeaderOverrideInContext(context map[string]interface{}, headerName string, value interface{}, keepOrigin bool) error { + headerName = strings.TrimSpace(headerName) + if headerName == "" { + return fmt.Errorf("header name is required") + } + if keepOrigin { + if _, exists := getHeaderValueFromContext(context, headerName); exists { + return nil + } + } + if value == nil { + return fmt.Errorf("header value is required") + } + headerValue := strings.TrimSpace(fmt.Sprintf("%v", value)) + if headerValue == "" { + return fmt.Errorf("header value is required") + } + + rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride) + rawHeaders[headerName] = headerValue + + normalizedHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverrideNormalized) + normalizedHeaders[normalizeHeaderContextKey(headerName)] = headerValue + return nil +} + +func copyHeaderInContext(context map[string]interface{}, fromHeader, toHeader string, keepOrigin bool) error { + fromHeader = strings.TrimSpace(fromHeader) + toHeader = strings.TrimSpace(toHeader) + if fromHeader == "" || toHeader == "" { + return fmt.Errorf("copy_header from/to is required") + } + value, exists := getHeaderValueFromContext(context, fromHeader) + if !exists { + return fmt.Errorf("source header does not exist: %s", fromHeader) + } + return setHeaderOverrideInContext(context, toHeader, value, keepOrigin) +} + +func moveHeaderInContext(context map[string]interface{}, fromHeader, toHeader string, keepOrigin bool) error { + fromHeader = strings.TrimSpace(fromHeader) + toHeader = strings.TrimSpace(toHeader) + if fromHeader == "" || toHeader == "" { + return fmt.Errorf("move_header from/to is required") + } + if err := copyHeaderInContext(context, fromHeader, toHeader, keepOrigin); err != nil { + return err + } + if strings.EqualFold(fromHeader, toHeader) { + return nil + } + return deleteHeaderOverrideInContext(context, fromHeader) +} + +func deleteHeaderOverrideInContext(context map[string]interface{}, headerName string) error { + headerName = strings.TrimSpace(headerName) + if headerName == "" { + return fmt.Errorf("header name is required") + } + rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride) + for key := range rawHeaders { + if strings.EqualFold(strings.TrimSpace(key), headerName) { + delete(rawHeaders, key) + } + } + + normalizedHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverrideNormalized) + delete(normalizedHeaders, normalizeHeaderContextKey(headerName)) + return nil +} + +func ensureMapKeyInContext(context map[string]interface{}, key string) map[string]interface{} { + if context == nil { + return map[string]interface{}{} + } + if existing, ok := context[key]; ok { + if mapVal, ok := existing.(map[string]interface{}); ok { + return mapVal + } + } + result := make(map[string]interface{}) + context[key] = result + return result +} + +func getHeaderValueFromContext(context map[string]interface{}, headerName string) (string, bool) { + headerName = strings.TrimSpace(headerName) + if headerName == "" { + return "", false + } + if value, ok := findHeaderValueInMap(ensureMapKeyInContext(context, paramOverrideContextHeaderOverride), headerName); ok { + return value, true + } + if value, ok := findHeaderValueInMap(ensureMapKeyInContext(context, paramOverrideContextRequestHeadersRaw), headerName); ok { + return value, true + } + + normalizedName := normalizeHeaderContextKey(headerName) + if normalizedName == "" { + return "", false + } + if value, ok := findHeaderValueInMap(ensureMapKeyInContext(context, paramOverrideContextHeaderOverrideNormalized), normalizedName); ok { + return value, true + } + if value, ok := findHeaderValueInMap(ensureMapKeyInContext(context, paramOverrideContextRequestHeaders), normalizedName); ok { + return value, true + } + return "", false +} + +func findHeaderValueInMap(source map[string]interface{}, key string) (string, bool) { + if len(source) == 0 { + return "", false + } + entries := lo.Entries(source) + entry, ok := lo.Find(entries, func(item lo.Entry[string, interface{}]) bool { + return strings.EqualFold(strings.TrimSpace(item.Key), key) + }) + if !ok { + return "", false + } + value := strings.TrimSpace(fmt.Sprintf("%v", entry.Value)) + if value == "" { + return "", false + } + return value, true +} + +func normalizeHeaderContextKey(key string) string { + key = strings.TrimSpace(strings.ToLower(key)) + if key == "" { + return "" + } + var b strings.Builder + b.Grow(len(key)) + previousUnderscore := false + for _, r := range key { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + previousUnderscore = false + case r >= '0' && r <= '9': + b.WriteRune(r) + previousUnderscore = false + default: + if !previousUnderscore { + b.WriteByte('_') + previousUnderscore = true + } + } + } + result := strings.Trim(b.String(), "_") + return result +} + +func buildNormalizedHeaders(headers map[string]string) map[string]interface{} { + if len(headers) == 0 { + return map[string]interface{}{} + } + entries := lo.Entries(headers) + normalizedEntries := lo.FilterMap(entries, func(item lo.Entry[string, string], _ int) (lo.Entry[string, string], bool) { + normalized := normalizeHeaderContextKey(item.Key) + value := strings.TrimSpace(item.Value) + if normalized == "" || value == "" { + return lo.Entry[string, string]{}, false + } + return lo.Entry[string, string]{Key: normalized, Value: value}, true + }) + return lo.SliceToMap(normalizedEntries, func(item lo.Entry[string, string]) (string, interface{}) { + return item.Key, item.Value + }) +} + +func buildRawHeaders(headers map[string]string) map[string]interface{} { + if len(headers) == 0 { + return map[string]interface{}{} + } + entries := lo.Entries(headers) + rawEntries := lo.FilterMap(entries, func(item lo.Entry[string, string], _ int) (lo.Entry[string, string], bool) { + key := strings.TrimSpace(item.Key) + value := strings.TrimSpace(item.Value) + if key == "" || value == "" { + return lo.Entry[string, string]{}, false + } + return lo.Entry[string, string]{Key: key, Value: value}, true + }) + return lo.SliceToMap(rawEntries, func(item lo.Entry[string, string]) (string, interface{}) { + return item.Key, item.Value + }) +} + +func buildHeaderOverrideContext(headers map[string]interface{}) (map[string]interface{}, map[string]interface{}) { + if len(headers) == 0 { + return map[string]interface{}{}, map[string]interface{}{} + } + entries := lo.Entries(headers) + rawEntries := lo.FilterMap(entries, func(item lo.Entry[string, interface{}], _ int) (lo.Entry[string, string], bool) { + key := strings.TrimSpace(item.Key) + value := strings.TrimSpace(fmt.Sprintf("%v", item.Value)) + if key == "" || value == "" { + return lo.Entry[string, string]{}, false + } + return lo.Entry[string, string]{Key: key, Value: value}, true + }) + + raw := lo.SliceToMap(rawEntries, func(item lo.Entry[string, string]) (string, interface{}) { + return item.Key, item.Value + }) + normalizedEntries := lo.FilterMap(rawEntries, func(item lo.Entry[string, string], _ int) (lo.Entry[string, string], bool) { + normalized := normalizeHeaderContextKey(item.Key) + if normalized == "" { + return lo.Entry[string, string]{}, false + } + return lo.Entry[string, string]{Key: normalized, Value: item.Value}, true + }) + normalized := lo.SliceToMap(normalizedEntries, func(item lo.Entry[string, string]) (string, interface{}) { + return item.Key, item.Value + }) + return raw, normalized +} + +func syncRuntimeHeaderOverrideFromContext(info *RelayInfo, context map[string]interface{}) { + if info == nil || context == nil { + return + } + raw, exists := context[paramOverrideContextHeaderOverride] + if !exists { + return + } + rawMap, ok := raw.(map[string]interface{}) + if !ok { + return + } + + entries := lo.Entries(rawMap) + sanitized := lo.FilterMap(entries, func(item lo.Entry[string, interface{}], _ int) (lo.Entry[string, interface{}], bool) { + key := strings.TrimSpace(item.Key) + if key == "" { + return lo.Entry[string, interface{}]{}, false + } + value := strings.TrimSpace(fmt.Sprintf("%v", item.Value)) + if value == "" { + return lo.Entry[string, interface{}]{}, false + } + return lo.Entry[string, interface{}]{Key: key, Value: value}, true + }) + info.RuntimeHeadersOverride = lo.SliceToMap(sanitized, func(item lo.Entry[string, interface{}]) (string, interface{}) { + return item.Key, item.Value + }) + info.UseRuntimeHeadersOverride = true +} + func moveValue(jsonStr, fromPath, toPath string) (string, error) { sourceValue := gjson.Get(jsonStr, fromPath) if !sourceValue.Exists() { @@ -824,38 +1135,56 @@ func parsePruneObjectsOptions(value interface{}) (pruneObjectsOptions, error) { } func parseConditionOperations(raw interface{}) ([]ConditionOperation, error) { - items, ok := raw.([]interface{}) - if !ok { - return nil, fmt.Errorf("conditions must be an array") + switch typed := raw.(type) { + case map[string]interface{}: + entries := lo.Entries(typed) + conditions := lo.FilterMap(entries, func(item lo.Entry[string, interface{}], _ int) (ConditionOperation, bool) { + path := strings.TrimSpace(item.Key) + if path == "" { + return ConditionOperation{}, false + } + return ConditionOperation{ + Path: path, + Mode: "full", + Value: item.Value, + }, true + }) + if len(conditions) == 0 { + return nil, fmt.Errorf("conditions object must contain at least one key") + } + return conditions, nil + case []interface{}: + items := typed + result := make([]ConditionOperation, 0, len(items)) + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("condition must be object") + } + path, _ := itemMap["path"].(string) + mode, _ := itemMap["mode"].(string) + if strings.TrimSpace(path) == "" || strings.TrimSpace(mode) == "" { + return nil, fmt.Errorf("condition path/mode is required") + } + condition := ConditionOperation{ + Path: path, + Mode: mode, + } + if value, exists := itemMap["value"]; exists { + condition.Value = value + } + if invert, ok := itemMap["invert"].(bool); ok { + condition.Invert = invert + } + if passMissingKey, ok := itemMap["pass_missing_key"].(bool); ok { + condition.PassMissingKey = passMissingKey + } + result = append(result, condition) + } + return result, nil + default: + return nil, fmt.Errorf("conditions must be an array or object") } - - result := make([]ConditionOperation, 0, len(items)) - for _, item := range items { - itemMap, ok := item.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("condition must be object") - } - path, _ := itemMap["path"].(string) - mode, _ := itemMap["mode"].(string) - if strings.TrimSpace(path) == "" || strings.TrimSpace(mode) == "" { - return nil, fmt.Errorf("condition path/mode is required") - } - condition := ConditionOperation{ - Path: path, - Mode: mode, - } - if value, exists := itemMap["value"]; exists { - condition.Value = value - } - if invert, ok := itemMap["invert"].(bool); ok { - condition.Invert = invert - } - if passMissingKey, ok := itemMap["pass_missing_key"].(bool); ok { - condition.PassMissingKey = passMissingKey - } - result = append(result, condition) - } - return result, nil } func pruneObjectsNode(node interface{}, options pruneObjectsOptions, contextJSON string, isRoot bool) (interface{}, bool, error) { @@ -970,6 +1299,17 @@ func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { } } + ctx[paramOverrideContextRequestHeaders] = buildNormalizedHeaders(info.RequestHeaders) + ctx[paramOverrideContextRequestHeadersRaw] = buildRawHeaders(info.RequestHeaders) + + headerOverrideSource := getHeaderOverrideMap(info) + if info.UseRuntimeHeadersOverride { + headerOverrideSource = info.RuntimeHeadersOverride + } + rawHeaderOverride, normalizedHeaderOverride := buildHeaderOverrideContext(headerOverrideSource) + ctx[paramOverrideContextHeaderOverride] = rawHeaderOverride + ctx[paramOverrideContextHeaderOverrideNormalized] = normalizedHeaderOverride + ctx["retry_index"] = info.RetryIndex ctx["is_retry"] = info.RetryIndex > 0 ctx["retry"] = map[string]interface{}{ diff --git a/relay/common/override_test.go b/relay/common/override_test.go index cc1489f74..653a87f6a 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -956,6 +956,254 @@ func TestApplyParamOverrideConditionFromRetryAndLastErrorContext(t *testing.T) { 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_normalized.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_normalized.x_upstream_auth", + "mode": "contains", + "value": "Bearer ", + }, + }, + }, + }, + } + ctx := map[string]interface{}{ + "request_headers_raw": map[string]interface{}{ + "Authorization": "Bearer token-123", + }, + "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 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", + }, + "header_override_normalized": 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 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 assertJSONEqual(t *testing.T, want, got string) { t.Helper() diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index c10e6d5fb..e5a0a06f5 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -101,6 +101,7 @@ type RelayInfo struct { RelayMode int OriginModelName string RequestURLPath string + RequestHeaders map[string]string ShouldIncludeUsage bool DisablePing bool // 是否禁止向下游发送自定义 Ping ClientWs *websocket.Conn @@ -142,6 +143,8 @@ type RelayInfo struct { IsChannelTest bool // channel test request RetryIndex int LastError *types.NewAPIError + RuntimeHeadersOverride map[string]interface{} + UseRuntimeHeadersOverride bool PriceData types.PriceData @@ -458,6 +461,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { isFirstResponse: true, RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path), RequestURLPath: c.Request.URL.String(), + RequestHeaders: cloneRequestHeaders(c), IsStream: isStream, StartTime: startTime, @@ -490,6 +494,27 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { 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) { var info *RelayInfo var err error diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 4cf5e0411..7f4b99488 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -172,7 +172,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types // apply param override if len(info.ParamOverride) > 0 { - jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) if err != nil { return newAPIErrorFromParamOverride(err) } diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index edbd1f7e6..d8ca42230 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -51,7 +51,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } if len(info.ParamOverride) > 0 { - jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) if err != nil { return newAPIErrorFromParamOverride(err) } diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index a58c404f5..39bd44e62 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -157,7 +157,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ // apply param override if len(info.ParamOverride) > 0 { - jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) if err != nil { return newAPIErrorFromParamOverride(err) } @@ -257,7 +257,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI // apply param override if len(info.ParamOverride) > 0 { - jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) if err != nil { return newAPIErrorFromParamOverride(err) } diff --git a/relay/image_handler.go b/relay/image_handler.go index 21a5be2fa..fc8ef500e 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -70,7 +70,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type // apply param override if len(info.ParamOverride) > 0 { - jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) if err != nil { return newAPIErrorFromParamOverride(err) } diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 9c4bef6e1..40d686f70 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -61,7 +61,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ // apply param override if len(info.ParamOverride) > 0 { - jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) if err != nil { return newAPIErrorFromParamOverride(err) } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 2190be87f..3bcaa673f 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -96,7 +96,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * // apply param override if len(info.ParamOverride) > 0 { - jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) if err != nil { return newAPIErrorFromParamOverride(err) } From 81d91730276312d8af3bca180b45d77434ba58f6 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 22 Feb 2026 01:17:26 +0800 Subject: [PATCH 03/13] feat: redesign param override editing with guided modal and Monaco JSON hints --- web/package.json | 2 + .../channels/modals/EditChannelModal.jsx | 154 +- .../modals/ParamOverrideEditorModal.jsx | 1409 +++++++++++++++++ 3 files changed, 1531 insertions(+), 34 deletions(-) create mode 100644 web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx diff --git a/web/package.json b/web/package.json index 97c7c821f..7d00d8c4a 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "@douyinfe/semi-icons": "^2.63.1", "@douyinfe/semi-ui": "^2.69.1", "@lobehub/icons": "^2.0.0", + "@monaco-editor/react": "^4.7.0", "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", @@ -20,6 +21,7 @@ "lucide-react": "^0.511.0", "marked": "^4.1.1", "mermaid": "^11.6.0", + "monaco-editor": "^0.55.1", "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 6e85ca982..d2e77cf7e 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -59,6 +59,7 @@ import ModelSelectModal from './ModelSelectModal'; import SingleModelSelectModal from './SingleModelSelectModal'; import OllamaModelModal from './OllamaModelModal'; import CodexOAuthModal from './CodexOAuthModal'; +import ParamOverrideEditorModal from './ParamOverrideEditorModal'; import JSONEditor from '../../../common/ui/JSONEditor'; import SecureVerificationModal from '../../../common/modals/SecureVerificationModal'; import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; @@ -143,6 +144,7 @@ const EditChannelModal = (props) => { base_url: '', other: '', model_mapping: '', + param_override: '', status_code_mapping: '', models: [], auto_ban: 1, @@ -224,11 +226,69 @@ const EditChannelModal = (props) => { return []; } }, [inputs.model_mapping]); + 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: 'Custom 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 [ionetMetadata, setIonetMetadata] = useState(null); const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false); const [codexCredentialRefreshing, setCodexCredentialRefreshing] = useState(false); + const [paramOverrideEditorVisible, setParamOverrideEditorVisible] = + useState(false); // 密钥显示状态 const [keyDisplayState, setKeyDisplayState] = useState({ @@ -1170,6 +1230,7 @@ const EditChannelModal = (props) => { const submit = async () => { const formValues = formApiRef.current ? formApiRef.current.getValues() : {}; let localInputs = { ...formValues }; + localInputs.param_override = inputs.param_override; if (localInputs.type === 57) { if (batch) { @@ -3043,28 +3104,20 @@ const EditChannelModal = (props) => { initValue={autoBan} /> - - handleInputChange('param_override', value) - } - extraText={ -
- +
+ {t('参数覆盖')} + + + + + +
+ + {t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数')} + +
+
+ + {paramOverrideMeta.tagLabel} + +
- } - showClear - /> +
+                          {paramOverrideMeta.preview}
+                        
+
+
{ /> + setParamOverrideEditorVisible(false)} + onSave={(nextValue) => { + handleInputChange('param_override', nextValue); + setParamOverrideEditorVisible(false); + }} + /> + . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import MonacoEditor from '@monaco-editor/react'; +import { useTranslation } from 'react-i18next'; +import { + Banner, + Button, + Card, + Col, + Input, + Modal, + Row, + Select, + Space, + Switch, + Tag, + Typography, +} from '@douyinfe/semi-ui'; +import { IconDelete, IconPlus } from '@douyinfe/semi-icons'; +import { showError, verifyJSON } from '../../../../helpers'; +import JSONEditor from '../../../common/ui/JSONEditor'; + +const { Text } = Typography; + +const OPERATION_MODE_OPTIONS = [ + { label: 'JSON · set', value: 'set' }, + { label: 'JSON · delete', value: 'delete' }, + { label: 'JSON · append', value: 'append' }, + { label: 'JSON · prepend', value: 'prepend' }, + { label: 'JSON · copy', value: 'copy' }, + { label: 'JSON · move', value: 'move' }, + { label: 'JSON · replace', value: 'replace' }, + { label: 'JSON · regex_replace', value: 'regex_replace' }, + { label: 'JSON · trim_prefix', value: 'trim_prefix' }, + { label: 'JSON · trim_suffix', value: 'trim_suffix' }, + { label: 'JSON · ensure_prefix', value: 'ensure_prefix' }, + { label: 'JSON · ensure_suffix', value: 'ensure_suffix' }, + { label: 'JSON · trim_space', value: 'trim_space' }, + { label: 'JSON · to_lower', value: 'to_lower' }, + { label: 'JSON · to_upper', value: 'to_upper' }, + { label: 'Control · return_error', value: 'return_error' }, + { label: 'Control · prune_objects', value: 'prune_objects' }, + { label: 'Header · set_header', value: 'set_header' }, + { label: 'Header · delete_header', value: 'delete_header' }, + { label: 'Header · copy_header', value: 'copy_header' }, + { label: 'Header · move_header', value: 'move_header' }, +]; + +const OPERATION_MODE_VALUES = new Set( + OPERATION_MODE_OPTIONS.map((item) => item.value), +); + +const CONDITION_MODE_OPTIONS = [ + { label: 'full', value: 'full' }, + { label: 'prefix', value: 'prefix' }, + { label: 'suffix', value: 'suffix' }, + { label: 'contains', value: 'contains' }, + { label: 'gt', value: 'gt' }, + { label: 'gte', value: 'gte' }, + { label: 'lt', value: 'lt' }, + { label: 'lte', value: 'lte' }, +]; + +const CONDITION_MODE_VALUES = new Set( + CONDITION_MODE_OPTIONS.map((item) => item.value), +); + +const MODE_META = { + delete: { path: true }, + set: { path: true, value: true, keepOrigin: true }, + append: { path: true, value: true, keepOrigin: true }, + prepend: { path: true, value: true, keepOrigin: true }, + copy: { from: true, to: true }, + move: { from: true, to: true }, + replace: { path: true, from: true, to: false }, + regex_replace: { path: true, from: true, to: false }, + trim_prefix: { path: true, value: true }, + trim_suffix: { path: true, value: true }, + ensure_prefix: { path: true, value: true }, + ensure_suffix: { path: true, value: true }, + trim_space: { path: true }, + to_lower: { path: true }, + to_upper: { path: true }, + return_error: { value: true }, + prune_objects: { pathOptional: true, value: true }, + set_header: { path: true, value: true, keepOrigin: true }, + delete_header: { path: true }, + copy_header: { from: true, to: true, keepOrigin: true, pathAlias: true }, + move_header: { from: true, to: true, keepOrigin: true, pathAlias: true }, +}; + +const VALUE_REQUIRED_MODES = new Set([ + 'trim_prefix', + 'trim_suffix', + 'ensure_prefix', + 'ensure_suffix', + 'set_header', + 'return_error', + 'prune_objects', +]); + +const FROM_REQUIRED_MODES = new Set([ + 'copy', + 'move', + 'replace', + 'regex_replace', + 'copy_header', + 'move_header', +]); + +const TO_REQUIRED_MODES = new Set(['copy', 'move', 'copy_header', 'move_header']); + +const MODE_DESCRIPTIONS = { + set: 'Set JSON value at path', + delete: 'Delete JSON field at path', + append: 'Append value to array/string/object', + prepend: 'Prepend value to array/string/object', + copy: 'Copy JSON value from from -> to', + move: 'Move JSON value from from -> to', + replace: 'String replace on target path', + regex_replace: 'Regex replace on target path', + trim_prefix: 'Trim prefix on string value', + trim_suffix: 'Trim suffix on string value', + ensure_prefix: 'Ensure string starts with prefix', + ensure_suffix: 'Ensure string ends with suffix', + trim_space: 'Trim spaces/newlines on string value', + to_lower: 'Convert string to lower case', + to_upper: 'Convert string to upper case', + return_error: 'Stop processing and return custom error', + prune_objects: 'Remove objects matching conditions', + set_header: 'Set runtime override header', + delete_header: 'Delete runtime override header', + copy_header: 'Copy header from from -> to', + move_header: 'Move header from from -> to', +}; + +const OPERATION_PATH_SUGGESTIONS = [ + 'model', + 'temperature', + 'max_tokens', + 'messages.-1.content', + 'metadata.conversation_id', +]; + +const CONDITION_PATH_SUGGESTIONS = [ + 'model', + 'retry.is_retry', + 'last_error.code', + 'request_headers.authorization', + 'header_override_normalized.x_debug_mode', +]; + +const LEGACY_TEMPLATE = { + temperature: 0, + max_tokens: 1000, +}; + +const OPERATION_TEMPLATE = { + operations: [ + { + path: 'temperature', + mode: 'set', + value: 0.7, + conditions: [ + { + path: 'model', + mode: 'prefix', + value: 'gpt', + }, + ], + logic: 'AND', + }, + ], +}; + +const MONACO_SCHEMA_URI = 'https://new-api.local/schemas/param-override.schema.json'; +const MONACO_MODEL_URI = 'inmemory://new-api/param-override.json'; + +const JSON_SCALAR_SCHEMA = { + oneOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'null' }, + { type: 'array' }, + { type: 'object' }, + ], +}; + +const PARAM_OVERRIDE_JSON_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + operations: { + type: 'array', + description: 'Operation pipeline for new param override format.', + items: { + type: 'object', + properties: { + mode: { + type: 'string', + enum: OPERATION_MODE_OPTIONS.map((item) => item.value), + }, + path: { type: 'string' }, + from: { type: 'string' }, + to: { type: 'string' }, + keep_origin: { type: 'boolean' }, + value: JSON_SCALAR_SCHEMA, + logic: { type: 'string', enum: ['AND', 'OR'] }, + conditions: { + oneOf: [ + { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string' }, + mode: { + type: 'string', + enum: CONDITION_MODE_OPTIONS.map((item) => item.value), + }, + value: JSON_SCALAR_SCHEMA, + invert: { type: 'boolean' }, + pass_missing_key: { type: 'boolean' }, + }, + required: ['path', 'mode'], + additionalProperties: false, + }, + }, + { + type: 'object', + additionalProperties: JSON_SCALAR_SCHEMA, + }, + ], + }, + }, + required: ['mode'], + additionalProperties: false, + allOf: [ + { + if: { properties: { mode: { const: 'set' } }, required: ['mode'] }, + then: { required: ['path'] }, + }, + { + if: { properties: { mode: { const: 'delete' } }, required: ['mode'] }, + then: { required: ['path'] }, + }, + { + if: { properties: { mode: { const: 'append' } }, required: ['mode'] }, + then: { required: ['path'] }, + }, + { + if: { properties: { mode: { const: 'prepend' } }, required: ['mode'] }, + then: { required: ['path'] }, + }, + { + if: { properties: { mode: { const: 'copy' } }, required: ['mode'] }, + then: { required: ['from', 'to'] }, + }, + { + if: { properties: { mode: { const: 'move' } }, required: ['mode'] }, + then: { required: ['from', 'to'] }, + }, + { + if: { properties: { mode: { const: 'replace' } }, required: ['mode'] }, + then: { required: ['path', 'from'] }, + }, + { + if: { + properties: { mode: { const: 'regex_replace' } }, + required: ['mode'], + }, + then: { required: ['path', 'from'] }, + }, + { + if: { + properties: { mode: { const: 'trim_prefix' } }, + required: ['mode'], + }, + then: { required: ['path', 'value'] }, + }, + { + if: { + properties: { mode: { const: 'trim_suffix' } }, + required: ['mode'], + }, + then: { required: ['path', 'value'] }, + }, + { + if: { + properties: { mode: { const: 'ensure_prefix' } }, + required: ['mode'], + }, + then: { required: ['path', 'value'] }, + }, + { + if: { + properties: { mode: { const: 'ensure_suffix' } }, + required: ['mode'], + }, + then: { required: ['path', 'value'] }, + }, + { + if: { + properties: { mode: { const: 'trim_space' } }, + required: ['mode'], + }, + then: { required: ['path'] }, + }, + { + if: { + properties: { mode: { const: 'to_lower' } }, + required: ['mode'], + }, + then: { required: ['path'] }, + }, + { + if: { + properties: { mode: { const: 'to_upper' } }, + required: ['mode'], + }, + then: { required: ['path'] }, + }, + { + if: { + properties: { mode: { const: 'return_error' } }, + required: ['mode'], + }, + then: { required: ['value'] }, + }, + { + if: { + properties: { mode: { const: 'prune_objects' } }, + required: ['mode'], + }, + then: { required: ['value'] }, + }, + { + if: { + properties: { mode: { const: 'set_header' } }, + required: ['mode'], + }, + then: { required: ['path', 'value'] }, + }, + { + if: { + properties: { mode: { const: 'delete_header' } }, + required: ['mode'], + }, + then: { required: ['path'] }, + }, + { + if: { + properties: { mode: { const: 'copy_header' } }, + required: ['mode'], + }, + then: { + anyOf: [{ required: ['path'] }, { required: ['from', 'to'] }], + }, + }, + { + if: { + properties: { mode: { const: 'move_header' } }, + required: ['mode'], + }, + then: { + anyOf: [{ required: ['path'] }, { required: ['from', 'to'] }], + }, + }, + ], + }, + }, + }, + additionalProperties: true, +}; + +let localIdSeed = 0; +const nextLocalId = () => `param_override_${Date.now()}_${localIdSeed++}`; + +const toValueText = (value) => { + if (value === undefined) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch (error) { + return String(value); + } +}; + +const parseLooseValue = (valueText) => { + const raw = String(valueText ?? ''); + if (raw.trim() === '') return ''; + try { + return JSON.parse(raw); + } catch (error) { + return raw; + } +}; + +const normalizeCondition = (condition = {}) => ({ + id: nextLocalId(), + path: typeof condition.path === 'string' ? condition.path : '', + mode: CONDITION_MODE_VALUES.has(condition.mode) ? condition.mode : 'full', + value_text: toValueText(condition.value), + invert: condition.invert === true, + pass_missing_key: condition.pass_missing_key === true, +}); + +const createDefaultCondition = () => normalizeCondition({}); + +const normalizeOperation = (operation = {}) => ({ + id: nextLocalId(), + path: typeof operation.path === 'string' ? operation.path : '', + mode: OPERATION_MODE_VALUES.has(operation.mode) ? operation.mode : 'set', + value_text: toValueText(operation.value), + keep_origin: operation.keep_origin === true, + from: typeof operation.from === 'string' ? operation.from : '', + to: typeof operation.to === 'string' ? operation.to : '', + logic: String(operation.logic || 'OR').toUpperCase() === 'AND' ? 'AND' : 'OR', + conditions: Array.isArray(operation.conditions) + ? operation.conditions.map(normalizeCondition) + : [], +}); + +const createDefaultOperation = () => normalizeOperation({ mode: 'set' }); + +const parseInitialState = (rawValue) => { + const text = typeof rawValue === 'string' ? rawValue : ''; + const trimmed = text.trim(); + if (!trimmed) { + return { + editMode: 'visual', + visualMode: 'operations', + legacyValue: '', + operations: [createDefaultOperation()], + jsonText: '', + jsonError: '', + }; + } + + if (!verifyJSON(trimmed)) { + return { + editMode: 'json', + visualMode: 'operations', + legacyValue: '', + operations: [createDefaultOperation()], + jsonText: text, + jsonError: 'JSON format is invalid', + }; + } + + const parsed = JSON.parse(trimmed); + const pretty = JSON.stringify(parsed, null, 2); + + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Array.isArray(parsed.operations) + ) { + return { + editMode: 'visual', + visualMode: 'operations', + legacyValue: '', + operations: + parsed.operations.length > 0 + ? parsed.operations.map(normalizeOperation) + : [createDefaultOperation()], + jsonText: pretty, + jsonError: '', + }; + } + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { + editMode: 'visual', + visualMode: 'legacy', + legacyValue: pretty, + operations: [createDefaultOperation()], + jsonText: pretty, + jsonError: '', + }; + } + + return { + editMode: 'json', + visualMode: 'operations', + legacyValue: '', + operations: [createDefaultOperation()], + jsonText: pretty, + jsonError: '', + }; +}; + +const isOperationBlank = (operation) => { + const hasCondition = (operation.conditions || []).some( + (condition) => + condition.path.trim() || + String(condition.value_text ?? '').trim() || + condition.mode !== 'full' || + condition.invert || + condition.pass_missing_key, + ); + return ( + operation.mode === 'set' && + !operation.path.trim() && + !operation.from.trim() && + !operation.to.trim() && + String(operation.value_text ?? '').trim() === '' && + !operation.keep_origin && + !hasCondition + ); +}; + +const buildConditionPayload = (condition) => { + const path = condition.path.trim(); + if (!path) return null; + const payload = { + path, + mode: condition.mode || 'full', + value: parseLooseValue(condition.value_text), + }; + if (condition.invert) payload.invert = true; + if (condition.pass_missing_key) payload.pass_missing_key = true; + return payload; +}; + +const validateOperations = (operations, t) => { + for (let i = 0; i < operations.length; i++) { + const op = operations[i]; + const mode = op.mode || 'set'; + const meta = MODE_META[mode] || MODE_META.set; + const line = i + 1; + const pathValue = op.path.trim(); + const fromValue = op.from.trim(); + const toValue = op.to.trim(); + + if (meta.path && !pathValue) { + return t('第 {{line}} 条操作缺少 path', { line }); + } + if (FROM_REQUIRED_MODES.has(mode) && !fromValue) { + if (!(meta.pathAlias && pathValue)) { + return t('第 {{line}} 条操作缺少 from', { line }); + } + } + if (TO_REQUIRED_MODES.has(mode) && !toValue) { + if (!(meta.pathAlias && pathValue)) { + return t('第 {{line}} 条操作缺少 to', { line }); + } + } + if (meta.from && !fromValue) { + return t('第 {{line}} 条操作缺少 from', { line }); + } + if (meta.to && !toValue) { + return t('第 {{line}} 条操作缺少 to', { line }); + } + if ( + VALUE_REQUIRED_MODES.has(mode) && + String(op.value_text ?? '').trim() === '' + ) { + return t('第 {{line}} 条操作缺少 value', { line }); + } + if (mode === 'return_error') { + const raw = String(op.value_text ?? '').trim(); + if (!raw) { + return t('第 {{line}} 条操作缺少 value', { line }); + } + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + if (!String(parsed.message || '').trim()) { + return t('第 {{line}} 条 return_error 需要 message', { line }); + } + } + } catch (error) { + // plain string value is allowed + } + } + } + return ''; +}; + +const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { + const { t } = useTranslation(); + + const [editMode, setEditMode] = useState('visual'); + const [visualMode, setVisualMode] = useState('operations'); + const [legacyValue, setLegacyValue] = useState(''); + const [operations, setOperations] = useState([createDefaultOperation()]); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(''); + const monacoConfiguredRef = useRef(false); + + const configureMonaco = useCallback((monaco) => { + if (monacoConfiguredRef.current) return; + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + allowComments: false, + enableSchemaRequest: false, + schemas: [ + { + uri: MONACO_SCHEMA_URI, + fileMatch: [MONACO_MODEL_URI, '*param-override*.json'], + schema: PARAM_OVERRIDE_JSON_SCHEMA, + }, + ], + }); + monacoConfiguredRef.current = true; + }, []); + + useEffect(() => { + if (!visible) return; + const nextState = parseInitialState(value); + setEditMode(nextState.editMode); + setVisualMode(nextState.visualMode); + setLegacyValue(nextState.legacyValue); + setOperations(nextState.operations); + setJsonText(nextState.jsonText); + setJsonError(nextState.jsonError); + }, [visible, value]); + + const operationCount = useMemo( + () => operations.filter((item) => !isOperationBlank(item)).length, + [operations], + ); + + const buildVisualJson = useCallback(() => { + if (visualMode === 'legacy') { + const trimmed = legacyValue.trim(); + if (!trimmed) return ''; + if (!verifyJSON(trimmed)) { + throw new Error(t('参数覆盖必须是合法的 JSON 格式!')); + } + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(t('旧格式必须是 JSON 对象')); + } + return JSON.stringify(parsed, null, 2); + } + + const filteredOps = operations.filter((item) => !isOperationBlank(item)); + if (filteredOps.length === 0) return ''; + + const message = validateOperations(filteredOps, t); + if (message) { + throw new Error(message); + } + + const payloadOps = filteredOps.map((operation) => { + const mode = operation.mode || 'set'; + const meta = MODE_META[mode] || MODE_META.set; + const pathValue = operation.path.trim(); + const fromValue = operation.from.trim(); + const toValue = operation.to.trim(); + const payload = { mode }; + if (meta.path) { + payload.path = pathValue; + } + if (meta.pathOptional && pathValue) { + payload.path = pathValue; + } + if (meta.value) { + payload.value = parseLooseValue(operation.value_text); + } + if (meta.keepOrigin && operation.keep_origin) { + payload.keep_origin = true; + } + if (meta.from) { + payload.from = fromValue; + } + if (!meta.to && operation.to.trim()) { + payload.to = toValue; + } + if (meta.to) { + payload.to = toValue; + } + if (meta.pathAlias) { + if (!payload.from && pathValue) { + payload.from = pathValue; + } + if (!payload.to && pathValue) { + payload.to = pathValue; + } + } + + const conditions = (operation.conditions || []) + .map(buildConditionPayload) + .filter(Boolean); + + if (conditions.length > 0) { + payload.conditions = conditions; + payload.logic = operation.logic === 'AND' ? 'AND' : 'OR'; + } + + return payload; + }); + + return JSON.stringify({ operations: payloadOps }, null, 2); + }, [legacyValue, operations, t, visualMode]); + + const switchToJsonMode = () => { + if (editMode === 'json') return; + try { + setJsonText(buildVisualJson()); + setJsonError(''); + setEditMode('json'); + } catch (error) { + showError(error.message); + } + }; + + const switchToVisualMode = () => { + if (editMode === 'visual') return; + const trimmed = jsonText.trim(); + if (!trimmed) { + setVisualMode('operations'); + setOperations([createDefaultOperation()]); + setLegacyValue(''); + setJsonError(''); + setEditMode('visual'); + return; + } + if (!verifyJSON(trimmed)) { + showError(t('参数覆盖必须是合法的 JSON 格式!')); + return; + } + const parsed = JSON.parse(trimmed); + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Array.isArray(parsed.operations) + ) { + setVisualMode('operations'); + setOperations( + parsed.operations.length > 0 + ? parsed.operations.map(normalizeOperation) + : [createDefaultOperation()], + ); + setLegacyValue(''); + setJsonError(''); + setEditMode('visual'); + return; + } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + setVisualMode('legacy'); + setLegacyValue(JSON.stringify(parsed, null, 2)); + setOperations([createDefaultOperation()]); + setJsonError(''); + setEditMode('visual'); + return; + } + showError(t('参数覆盖必须是合法的 JSON 对象')); + }; + + const setOldTemplate = () => { + const text = JSON.stringify(LEGACY_TEMPLATE, null, 2); + setVisualMode('legacy'); + setLegacyValue(text); + setJsonText(text); + setJsonError(''); + setEditMode('visual'); + }; + + const setNewTemplate = () => { + setVisualMode('operations'); + setOperations(OPERATION_TEMPLATE.operations.map(normalizeOperation)); + setJsonText(JSON.stringify(OPERATION_TEMPLATE, null, 2)); + setJsonError(''); + setEditMode('visual'); + }; + + const clearValue = () => { + setVisualMode('operations'); + setLegacyValue(''); + setOperations([createDefaultOperation()]); + setJsonText(''); + setJsonError(''); + }; + + const updateOperation = (operationId, patch) => { + setOperations((prev) => + prev.map((item) => + item.id === operationId ? { ...item, ...patch } : item, + ), + ); + }; + + const addOperation = () => { + setOperations((prev) => [...prev, createDefaultOperation()]); + }; + + const duplicateOperation = (operationId) => { + setOperations((prev) => { + const index = prev.findIndex((item) => item.id === operationId); + if (index < 0) return prev; + const source = prev[index]; + const cloned = normalizeOperation({ + path: source.path, + mode: source.mode, + value: parseLooseValue(source.value_text), + keep_origin: source.keep_origin, + from: source.from, + to: source.to, + logic: source.logic, + conditions: (source.conditions || []).map((condition) => ({ + path: condition.path, + mode: condition.mode, + value: parseLooseValue(condition.value_text), + invert: condition.invert, + pass_missing_key: condition.pass_missing_key, + })), + }); + const next = [...prev]; + next.splice(index + 1, 0, cloned); + return next; + }); + }; + + const removeOperation = (operationId) => { + setOperations((prev) => { + if (prev.length <= 1) return [createDefaultOperation()]; + return prev.filter((item) => item.id !== operationId); + }); + }; + + const addCondition = (operationId) => { + setOperations((prev) => + prev.map((operation) => + operation.id === operationId + ? { + ...operation, + conditions: [...(operation.conditions || []), createDefaultCondition()], + } + : operation, + ), + ); + }; + + const updateCondition = (operationId, conditionId, patch) => { + setOperations((prev) => + prev.map((operation) => { + if (operation.id !== operationId) return operation; + return { + ...operation, + conditions: (operation.conditions || []).map((condition) => + condition.id === conditionId + ? { ...condition, ...patch } + : condition, + ), + }; + }), + ); + }; + + const removeCondition = (operationId, conditionId) => { + setOperations((prev) => + prev.map((operation) => { + if (operation.id !== operationId) return operation; + return { + ...operation, + conditions: (operation.conditions || []).filter( + (condition) => condition.id !== conditionId, + ), + }; + }), + ); + }; + + const handleJsonChange = (nextValue) => { + setJsonText(nextValue); + const trimmed = String(nextValue || '').trim(); + if (!trimmed) { + setJsonError(''); + return; + } + if (!verifyJSON(trimmed)) { + setJsonError(t('JSON格式错误')); + return; + } + setJsonError(''); + }; + + const formatJson = () => { + const trimmed = jsonText.trim(); + if (!trimmed) return; + if (!verifyJSON(trimmed)) { + showError(t('参数覆盖必须是合法的 JSON 格式!')); + return; + } + setJsonText(JSON.stringify(JSON.parse(trimmed), null, 2)); + setJsonError(''); + }; + + const visualPreview = useMemo(() => { + if (editMode !== 'visual' || visualMode !== 'operations') { + return ''; + } + try { + return buildVisualJson() || ''; + } catch (error) { + return `// ${error.message}`; + } + }, [buildVisualJson, editMode, visualMode]); + + const handleSave = () => { + try { + let result = ''; + if (editMode === 'json') { + const trimmed = jsonText.trim(); + if (!trimmed) { + result = ''; + } else { + if (!verifyJSON(trimmed)) { + throw new Error(t('参数覆盖必须是合法的 JSON 格式!')); + } + result = JSON.stringify(JSON.parse(trimmed), null, 2); + } + } else { + result = buildVisualJson(); + } + onSave?.(result); + } catch (error) { + showError(error.message); + } + }; + + return ( + + + + + + + + + + + + + {editMode === 'visual' ? ( +
+ + + + + + {visualMode === 'legacy' ? ( + + ) : ( +
+
+ + {t('新格式(支持条件判断与json自定义):')} + + {`${t('规则')}: ${operationCount}`} + + + +
+ + + {operations.map((operation, index) => { + const mode = operation.mode || 'set'; + const meta = MODE_META[mode] || MODE_META.set; + const conditions = operation.conditions || []; + return ( + +
+ + {`#${index + 1}`} + {mode} + + + +
+ + + + + mode + + + updateOperation(operation.id, { + path: nextValue, + }) + } + /> + + {OPERATION_PATH_SUGGESTIONS.map((pathItem) => ( + + updateOperation(operation.id, { + path: pathItem, + }) + } + > + {pathItem} + + ))} + + + ) : null} + + + {MODE_DESCRIPTIONS[mode] || ''} + + + {meta.value ? ( +
+ + value (JSON or plain text) + + + updateOperation(operation.id, { + value_text: nextValue, + }) + } + /> +
+ ) : null} + + {meta.keepOrigin ? ( +
+ + updateOperation(operation.id, { + keep_origin: nextValue, + }) + } + /> + + keep_origin + +
+ ) : null} + + {meta.from || meta.to === false || meta.to ? ( + + {meta.from || meta.to === false ? ( + + + from + + + updateOperation(operation.id, { + from: nextValue, + }) + } + /> + + ) : null} + {meta.to || meta.to === false ? ( + + + to + + + updateOperation(operation.id, { to: nextValue }) + } + /> + + ) : null} + + ) : null} + +
+
+ + {t('条件')} + + updateCondition( + operation.id, + condition.id, + { path: nextValue }, + ) + } + /> + + {CONDITION_PATH_SUGGESTIONS.map( + (pathItem) => ( + + updateCondition( + operation.id, + condition.id, + { path: pathItem }, + ) + } + > + {pathItem} + + ), + )} + + + + + mode + + + updateCondition( + operation.id, + condition.id, + { value_text: nextValue }, + ) + } + /> + + + + + updateCondition( + operation.id, + condition.id, + { invert: nextValue }, + ) + } + /> + + invert + + + updateCondition( + operation.id, + condition.id, + { pass_missing_key: nextValue }, + ) + } + /> + + pass_missing_key + + + + ))} + + )} +
+ + ); + })} + + +
+ {t('实时 JSON 预览')} + {t('预览')} +
+
+                    {visualPreview || '{}'}
+                  
+
+
+ )} +
+ ) : ( +
+ + + {t('JSON 智能提示')} + +
+ handleJsonChange(nextValue ?? '')} + height='460px' + options={{ + minimap: { enabled: false }, + fontSize: 13, + lineNumbers: 'on', + automaticLayout: true, + scrollBeyondLastLine: false, + tabSize: 2, + insertSpaces: true, + wordWrap: 'on', + formatOnPaste: true, + formatOnType: true, + }} + /> +
+ + {t('支持 mode/conditions 字段补全与 JSON Schema 校验')} + + {jsonError ? ( + {jsonError} + ) : null} +
+ )} + + + ); +}; + +export default ParamOverrideEditorModal; From 285d7233a3faf720a0c0bac6284a4fc6182167bf Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 22 Feb 2026 01:27:58 +0800 Subject: [PATCH 04/13] feat: sync field --- relay/common/override.go | 120 +++++++++++++- relay/common/override_test.go | 107 ++++++++++++ .../modals/ParamOverrideEditorModal.jsx | 154 +++++++++++++++++- 3 files changed, 378 insertions(+), 3 deletions(-) diff --git a/relay/common/override.go b/relay/common/override.go index 9ac007ecd..31101ef01 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -34,7 +34,7 @@ type ConditionOperation struct { type ParamOperation struct { Path string `json:"path"` - Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace, return_error, prune_objects, set_header, delete_header, copy_header, move_header + Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace, return_error, prune_objects, set_header, delete_header, copy_header, move_header, sync_fields Value interface{} `json:"value"` KeepOrigin bool `json:"keep_origin"` From string `json:"from,omitempty"` @@ -494,6 +494,11 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte if err == nil { contextJSON, err = marshalContextJSON(context) } + case "sync_fields": + result, err = syncFieldsBetweenTargets(result, context, op.From, op.To) + if err == nil { + contextJSON, err = marshalContextJSON(context) + } default: return "", fmt.Errorf("unknown operation: %s", op.Mode) } @@ -673,6 +678,119 @@ func deleteHeaderOverrideInContext(context map[string]interface{}, headerName st return nil } +type syncTarget struct { + kind string + key string +} + +func parseSyncTarget(spec string) (syncTarget, error) { + raw := strings.TrimSpace(spec) + if raw == "" { + return syncTarget{}, fmt.Errorf("sync_fields target is required") + } + + idx := strings.Index(raw, ":") + if idx < 0 { + // Backward compatibility: treat bare value as JSON path. + return syncTarget{ + kind: "json", + key: raw, + }, nil + } + + kind := strings.ToLower(strings.TrimSpace(raw[:idx])) + key := strings.TrimSpace(raw[idx+1:]) + if key == "" { + return syncTarget{}, fmt.Errorf("sync_fields target key is required: %s", raw) + } + + switch kind { + case "json", "body": + return syncTarget{ + kind: "json", + key: key, + }, nil + case "header": + return syncTarget{ + kind: "header", + key: key, + }, nil + default: + return syncTarget{}, fmt.Errorf("sync_fields target prefix is invalid: %s", raw) + } +} + +func readSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget) (interface{}, bool, error) { + switch target.kind { + case "json": + path := processNegativeIndex(jsonStr, target.key) + value := gjson.Get(jsonStr, path) + if !value.Exists() || value.Type == gjson.Null { + return nil, false, nil + } + if value.Type == gjson.String && strings.TrimSpace(value.String()) == "" { + return nil, false, nil + } + return value.Value(), true, nil + case "header": + value, ok := getHeaderValueFromContext(context, target.key) + if !ok || strings.TrimSpace(value) == "" { + return nil, false, nil + } + return value, true, nil + default: + return nil, false, fmt.Errorf("unsupported sync_fields target kind: %s", target.kind) + } +} + +func writeSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget, value interface{}) (string, error) { + switch target.kind { + case "json": + path := processNegativeIndex(jsonStr, target.key) + nextJSON, err := sjson.Set(jsonStr, path, value) + if err != nil { + return "", err + } + return nextJSON, nil + case "header": + if err := setHeaderOverrideInContext(context, target.key, value, false); err != nil { + return "", err + } + return jsonStr, nil + default: + return "", fmt.Errorf("unsupported sync_fields target kind: %s", target.kind) + } +} + +func syncFieldsBetweenTargets(jsonStr string, context map[string]interface{}, fromSpec string, toSpec string) (string, error) { + fromTarget, err := parseSyncTarget(fromSpec) + if err != nil { + return "", err + } + toTarget, err := parseSyncTarget(toSpec) + if err != nil { + return "", err + } + + fromValue, fromExists, err := readSyncTargetValue(jsonStr, context, fromTarget) + if err != nil { + return "", err + } + toValue, toExists, err := readSyncTargetValue(jsonStr, context, toTarget) + if err != nil { + return "", err + } + + // If one side exists and the other side is missing, sync the missing side. + if fromExists && !toExists { + return writeSyncTargetValue(jsonStr, context, toTarget, fromValue) + } + if toExists && !fromExists { + return writeSyncTargetValue(jsonStr, context, fromTarget, toValue) + } + return jsonStr, nil +} + func ensureMapKeyInContext(context map[string]interface{}, key string) map[string]interface{} { if context == nil { return map[string]interface{}{} diff --git a/relay/common/override_test.go b/relay/common/override_test.go index 653a87f6a..a37eb78f9 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -1057,6 +1057,113 @@ func TestApplyParamOverrideCopyHeaderFromRequestHeaders(t *testing.T) { assertJSONEqual(t, `{"temperature":0.1}`, string(out)) } +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_raw": map[string]interface{}{ + "session_id": "sess-123", + }, + "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_raw": map[string]interface{}{ + "session_id": "cache-header", + }, + "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{}{ diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index 1975e83fd..42051d5bf 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx @@ -58,6 +58,7 @@ const OPERATION_MODE_OPTIONS = [ { label: 'JSON · to_upper', value: 'to_upper' }, { label: 'Control · return_error', value: 'return_error' }, { label: 'Control · prune_objects', value: 'prune_objects' }, + { label: 'Control · sync_fields', value: 'sync_fields' }, { label: 'Header · set_header', value: 'set_header' }, { label: 'Header · delete_header', value: 'delete_header' }, { label: 'Header · copy_header', value: 'copy_header' }, @@ -101,6 +102,7 @@ const MODE_META = { to_upper: { path: true }, return_error: { value: true }, prune_objects: { pathOptional: true, value: true }, + sync_fields: { from: true, to: true }, set_header: { path: true, value: true, keepOrigin: true }, delete_header: { path: true }, copy_header: { from: true, to: true, keepOrigin: true, pathAlias: true }, @@ -124,9 +126,16 @@ const FROM_REQUIRED_MODES = new Set([ 'regex_replace', 'copy_header', 'move_header', + 'sync_fields', ]); -const TO_REQUIRED_MODES = new Set(['copy', 'move', 'copy_header', 'move_header']); +const TO_REQUIRED_MODES = new Set([ + 'copy', + 'move', + 'copy_header', + 'move_header', + 'sync_fields', +]); const MODE_DESCRIPTIONS = { set: 'Set JSON value at path', @@ -146,12 +155,18 @@ const MODE_DESCRIPTIONS = { to_upper: 'Convert string to upper case', return_error: 'Stop processing and return custom error', prune_objects: 'Remove objects matching conditions', + sync_fields: 'Sync two fields when one exists and the other is missing', set_header: 'Set runtime override header', delete_header: 'Delete runtime override header', copy_header: 'Copy header from from -> to', move_header: 'Move header from from -> to', }; +const SYNC_TARGET_TYPE_OPTIONS = [ + { label: 'JSON', value: 'json' }, + { label: 'Header', value: 'header' }, +]; + const OPERATION_PATH_SUGGESTIONS = [ 'model', 'temperature', @@ -353,6 +368,13 @@ const PARAM_OVERRIDE_JSON_SCHEMA = { }, then: { required: ['value'] }, }, + { + if: { + properties: { mode: { const: 'sync_fields' } }, + required: ['mode'], + }, + then: { required: ['from', 'to'] }, + }, { if: { properties: { mode: { const: 'set_header' } }, @@ -415,6 +437,26 @@ const parseLooseValue = (valueText) => { } }; +const parseSyncTargetSpec = (spec) => { + const raw = String(spec ?? '').trim(); + if (!raw) return { type: 'json', key: '' }; + const idx = raw.indexOf(':'); + if (idx < 0) return { type: 'json', key: raw }; + const prefix = raw.slice(0, idx).trim().toLowerCase(); + const key = raw.slice(idx + 1).trim(); + if (prefix === 'header') { + return { type: 'header', key }; + } + return { type: 'json', key }; +}; + +const buildSyncTargetSpec = (type, key) => { + const normalizedType = type === 'header' ? 'header' : 'json'; + const normalizedKey = String(key ?? '').trim(); + if (!normalizedKey) return ''; + return `${normalizedType}:${normalizedKey}`; +}; + const normalizeCondition = (condition = {}) => ({ id: nextLocalId(), path: typeof condition.path === 'string' ? condition.path : '', @@ -1028,6 +1070,14 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { const mode = operation.mode || 'set'; const meta = MODE_META[mode] || MODE_META.set; const conditions = operation.conditions || []; + const syncFromTarget = + mode === 'sync_fields' + ? parseSyncTargetSpec(operation.from) + : null; + const syncToTarget = + mode === 'sync_fields' + ? parseSyncTargetSpec(operation.to) + : null; return (
@@ -1146,7 +1196,107 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
) : null} - {meta.from || meta.to === false || meta.to ? ( + {mode === 'sync_fields' ? ( +
+ + sync endpoints + + + + + from endpoint + +
+ + updateOperation(operation.id, { + from: buildSyncTargetSpec( + syncFromTarget?.type || 'json', + nextKey, + ), + }) + } + /> +
+ + + + to endpoint + +
+ + updateOperation(operation.id, { + to: buildSyncTargetSpec( + syncToTarget?.type || 'json', + nextKey, + ), + }) + } + /> +
+ +
+ + + updateOperation(operation.id, { + from: 'header:session_id', + to: 'json:prompt_cache_key', + }) + } + > + {'header:session_id -> json:prompt_cache_key'} + + + updateOperation(operation.id, { + from: 'json:prompt_cache_key', + to: 'header:session_id', + }) + } + > + {'json:prompt_cache_key -> header:session_id'} + + +
+ ) : meta.from || meta.to === false || meta.to ? ( {meta.from || meta.to === false ? ( From c72dfef91e1bea690c92570a099312b4e19b2c63 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 22 Feb 2026 01:48:26 +0800 Subject: [PATCH 05/13] rm editor --- web/package.json | 4 +- .../modals/ParamOverrideEditorModal.jsx | 287 ++---------------- 2 files changed, 24 insertions(+), 267 deletions(-) diff --git a/web/package.json b/web/package.json index 7d00d8c4a..4d8c7e7f5 100644 --- a/web/package.json +++ b/web/package.json @@ -7,11 +7,10 @@ "@douyinfe/semi-icons": "^2.63.1", "@douyinfe/semi-ui": "^2.69.1", "@lobehub/icons": "^2.0.0", - "@monaco-editor/react": "^4.7.0", "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", - "axios": "1.13.5", + "axios": "1.12.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "history": "^5.3.0", @@ -21,7 +20,6 @@ "lucide-react": "^0.511.0", "marked": "^4.1.1", "mermaid": "^11.6.0", - "monaco-editor": "^0.55.1", "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index 42051d5bf..50dc4949a 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx @@ -17,8 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import MonacoEditor from '@monaco-editor/react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Banner, @@ -36,7 +35,6 @@ import { } from '@douyinfe/semi-ui'; import { IconDelete, IconPlus } from '@douyinfe/semi-icons'; import { showError, verifyJSON } from '../../../../helpers'; -import JSONEditor from '../../../common/ui/JSONEditor'; const { Text } = Typography; @@ -206,214 +204,6 @@ const OPERATION_TEMPLATE = { ], }; -const MONACO_SCHEMA_URI = 'https://new-api.local/schemas/param-override.schema.json'; -const MONACO_MODEL_URI = 'inmemory://new-api/param-override.json'; - -const JSON_SCALAR_SCHEMA = { - oneOf: [ - { type: 'string' }, - { type: 'number' }, - { type: 'boolean' }, - { type: 'null' }, - { type: 'array' }, - { type: 'object' }, - ], -}; - -const PARAM_OVERRIDE_JSON_SCHEMA = { - $schema: 'http://json-schema.org/draft-07/schema#', - type: 'object', - properties: { - operations: { - type: 'array', - description: 'Operation pipeline for new param override format.', - items: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: OPERATION_MODE_OPTIONS.map((item) => item.value), - }, - path: { type: 'string' }, - from: { type: 'string' }, - to: { type: 'string' }, - keep_origin: { type: 'boolean' }, - value: JSON_SCALAR_SCHEMA, - logic: { type: 'string', enum: ['AND', 'OR'] }, - conditions: { - oneOf: [ - { - type: 'array', - items: { - type: 'object', - properties: { - path: { type: 'string' }, - mode: { - type: 'string', - enum: CONDITION_MODE_OPTIONS.map((item) => item.value), - }, - value: JSON_SCALAR_SCHEMA, - invert: { type: 'boolean' }, - pass_missing_key: { type: 'boolean' }, - }, - required: ['path', 'mode'], - additionalProperties: false, - }, - }, - { - type: 'object', - additionalProperties: JSON_SCALAR_SCHEMA, - }, - ], - }, - }, - required: ['mode'], - additionalProperties: false, - allOf: [ - { - if: { properties: { mode: { const: 'set' } }, required: ['mode'] }, - then: { required: ['path'] }, - }, - { - if: { properties: { mode: { const: 'delete' } }, required: ['mode'] }, - then: { required: ['path'] }, - }, - { - if: { properties: { mode: { const: 'append' } }, required: ['mode'] }, - then: { required: ['path'] }, - }, - { - if: { properties: { mode: { const: 'prepend' } }, required: ['mode'] }, - then: { required: ['path'] }, - }, - { - if: { properties: { mode: { const: 'copy' } }, required: ['mode'] }, - then: { required: ['from', 'to'] }, - }, - { - if: { properties: { mode: { const: 'move' } }, required: ['mode'] }, - then: { required: ['from', 'to'] }, - }, - { - if: { properties: { mode: { const: 'replace' } }, required: ['mode'] }, - then: { required: ['path', 'from'] }, - }, - { - if: { - properties: { mode: { const: 'regex_replace' } }, - required: ['mode'], - }, - then: { required: ['path', 'from'] }, - }, - { - if: { - properties: { mode: { const: 'trim_prefix' } }, - required: ['mode'], - }, - then: { required: ['path', 'value'] }, - }, - { - if: { - properties: { mode: { const: 'trim_suffix' } }, - required: ['mode'], - }, - then: { required: ['path', 'value'] }, - }, - { - if: { - properties: { mode: { const: 'ensure_prefix' } }, - required: ['mode'], - }, - then: { required: ['path', 'value'] }, - }, - { - if: { - properties: { mode: { const: 'ensure_suffix' } }, - required: ['mode'], - }, - then: { required: ['path', 'value'] }, - }, - { - if: { - properties: { mode: { const: 'trim_space' } }, - required: ['mode'], - }, - then: { required: ['path'] }, - }, - { - if: { - properties: { mode: { const: 'to_lower' } }, - required: ['mode'], - }, - then: { required: ['path'] }, - }, - { - if: { - properties: { mode: { const: 'to_upper' } }, - required: ['mode'], - }, - then: { required: ['path'] }, - }, - { - if: { - properties: { mode: { const: 'return_error' } }, - required: ['mode'], - }, - then: { required: ['value'] }, - }, - { - if: { - properties: { mode: { const: 'prune_objects' } }, - required: ['mode'], - }, - then: { required: ['value'] }, - }, - { - if: { - properties: { mode: { const: 'sync_fields' } }, - required: ['mode'], - }, - then: { required: ['from', 'to'] }, - }, - { - if: { - properties: { mode: { const: 'set_header' } }, - required: ['mode'], - }, - then: { required: ['path', 'value'] }, - }, - { - if: { - properties: { mode: { const: 'delete_header' } }, - required: ['mode'], - }, - then: { required: ['path'] }, - }, - { - if: { - properties: { mode: { const: 'copy_header' } }, - required: ['mode'], - }, - then: { - anyOf: [{ required: ['path'] }, { required: ['from', 'to'] }], - }, - }, - { - if: { - properties: { mode: { const: 'move_header' } }, - required: ['mode'], - }, - then: { - anyOf: [{ required: ['path'] }, { required: ['from', 'to'] }], - }, - }, - ], - }, - }, - }, - additionalProperties: true, -}; - let localIdSeed = 0; const nextLocalId = () => `param_override_${Date.now()}_${localIdSeed++}`; @@ -649,24 +439,6 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { const [operations, setOperations] = useState([createDefaultOperation()]); const [jsonText, setJsonText] = useState(''); const [jsonError, setJsonError] = useState(''); - const monacoConfiguredRef = useRef(false); - - const configureMonaco = useCallback((monaco) => { - if (monacoConfiguredRef.current) return; - monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ - validate: true, - allowComments: false, - enableSchemaRequest: false, - schemas: [ - { - uri: MONACO_SCHEMA_URI, - fileMatch: [MONACO_MODEL_URI, '*param-override*.json'], - schema: PARAM_OVERRIDE_JSON_SCHEMA, - }, - ], - }); - monacoConfiguredRef.current = true; - }, []); useEffect(() => { if (!visible) return; @@ -1040,17 +812,19 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { {visualMode === 'legacy' ? ( - +
+ {t('旧格式(直接覆盖):')} + setLegacyValue(nextValue)} + showClear + /> + + {t('这里直接编辑 JSON 对象,无需额外点开编辑器。')} + +
) : (
@@ -1519,32 +1293,17 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
- {t('JSON 智能提示')} + {t('普通编辑')} -
- handleJsonChange(nextValue ?? '')} - height='460px' - options={{ - minimap: { enabled: false }, - fontSize: 13, - lineNumbers: 'on', - automaticLayout: true, - scrollBeyondLastLine: false, - tabSize: 2, - insertSpaces: true, - wordWrap: 'on', - formatOnPaste: true, - formatOnType: true, - }} - /> -
+ handleJsonChange(nextValue ?? '')} + placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)} + showClear + /> - {t('支持 mode/conditions 字段补全与 JSON Schema 校验')} + {t('直接编辑 JSON 文本,保存时会校验格式。')} {jsonError ? ( {jsonError} From 11b0788b68615347b0a9e16c2ed6865d12f551ce Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 22 Feb 2026 13:57:13 +0800 Subject: [PATCH 06/13] fix --- .../modals/ParamOverrideEditorModal.jsx | 1373 +++++++++++------ 1 file changed, 906 insertions(+), 467 deletions(-) diff --git a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx index 50dc4949a..e33d68bef 100644 --- a/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx +++ b/web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx @@ -20,10 +20,10 @@ For commercial licensing, please contact support@quantumnous.com import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { - Banner, Button, Card, Col, + Collapse, Input, Modal, Row, @@ -31,6 +31,7 @@ import { Space, Switch, Tag, + TextArea, Typography, } from '@douyinfe/semi-ui'; import { IconDelete, IconPlus } from '@douyinfe/semi-icons'; @@ -204,6 +205,11 @@ const OPERATION_TEMPLATE = { ], }; +const TEMPLATE_LIBRARY_OPTIONS = [ + { label: 'Template · Operations', value: 'operations' }, + { label: 'Template · Legacy Object', value: 'legacy' }, +]; + let localIdSeed = 0; const nextLocalId = () => `param_override_${Date.now()}_${localIdSeed++}`; @@ -274,6 +280,28 @@ const normalizeOperation = (operation = {}) => ({ const createDefaultOperation = () => normalizeOperation({ mode: 'set' }); +const getOperationSummary = (operation = {}, index = 0) => { + const mode = operation.mode || 'set'; + if (mode === 'sync_fields') { + const from = String(operation.from || '').trim(); + const to = String(operation.to || '').trim(); + return `${index + 1}. ${mode} · ${from || to || '-'}`; + } + const path = String(operation.path || '').trim(); + const from = String(operation.from || '').trim(); + const to = String(operation.to || '').trim(); + return `${index + 1}. ${mode} · ${path || from || to || '-'}`; +}; + +const getOperationModeTagColor = (mode = 'set') => { + if (mode.includes('header')) return 'cyan'; + if (mode.includes('replace') || mode.includes('trim')) return 'violet'; + if (mode.includes('copy') || mode.includes('move')) return 'blue'; + if (mode.includes('error') || mode.includes('prune')) return 'red'; + if (mode.includes('sync')) return 'green'; + return 'grey'; +}; + const parseInitialState = (rawValue) => { const text = typeof rawValue === 'string' ? rawValue : ''; const trimmed = text.trim(); @@ -439,6 +467,10 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { const [operations, setOperations] = useState([createDefaultOperation()]); const [jsonText, setJsonText] = useState(''); const [jsonError, setJsonError] = useState(''); + const [operationSearch, setOperationSearch] = useState(''); + const [selectedOperationId, setSelectedOperationId] = useState(''); + const [expandedConditionMap, setExpandedConditionMap] = useState({}); + const [templateLibraryKey, setTemplateLibraryKey] = useState('operations'); useEffect(() => { if (!visible) return; @@ -449,13 +481,73 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { setOperations(nextState.operations); setJsonText(nextState.jsonText); setJsonError(nextState.jsonError); + setOperationSearch(''); + setSelectedOperationId(nextState.operations[0]?.id || ''); + setExpandedConditionMap({}); + setTemplateLibraryKey( + nextState.visualMode === 'legacy' ? 'legacy' : 'operations', + ); }, [visible, value]); + useEffect(() => { + if (operations.length === 0) { + setSelectedOperationId(''); + return; + } + if (!operations.some((item) => item.id === selectedOperationId)) { + setSelectedOperationId(operations[0].id); + } + }, [operations, selectedOperationId]); + + useEffect(() => { + setTemplateLibraryKey(visualMode === 'legacy' ? 'legacy' : 'operations'); + }, [visualMode]); + const operationCount = useMemo( () => operations.filter((item) => !isOperationBlank(item)).length, [operations], ); + const filteredOperations = useMemo(() => { + const keyword = operationSearch.trim().toLowerCase(); + if (!keyword) return operations; + return operations.filter((operation) => { + const searchableText = [ + operation.mode, + operation.path, + operation.from, + operation.to, + operation.value_text, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return searchableText.includes(keyword); + }); + }, [operationSearch, operations]); + + const selectedOperation = useMemo( + () => operations.find((operation) => operation.id === selectedOperationId), + [operations, selectedOperationId], + ); + + const selectedOperationIndex = useMemo( + () => + operations.findIndex((operation) => operation.id === selectedOperationId), + [operations, selectedOperationId], + ); + + const topOperationModes = useMemo(() => { + const counts = operations.reduce((acc, operation) => { + const mode = operation.mode || 'set'; + acc[mode] = (acc[mode] || 0) + 1; + return acc; + }, {}); + return Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4); + }, [operations]); + const buildVisualJson = useCallback(() => { if (visualMode === 'legacy') { const trimmed = legacyValue.trim(); @@ -545,8 +637,10 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { if (editMode === 'visual') return; const trimmed = jsonText.trim(); if (!trimmed) { + const fallback = createDefaultOperation(); setVisualMode('operations'); - setOperations([createDefaultOperation()]); + setOperations([fallback]); + setSelectedOperationId(fallback.id); setLegacyValue(''); setJsonError(''); setEditMode('visual'); @@ -563,21 +657,24 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { !Array.isArray(parsed) && Array.isArray(parsed.operations) ) { - setVisualMode('operations'); - setOperations( + const nextOperations = parsed.operations.length > 0 ? parsed.operations.map(normalizeOperation) - : [createDefaultOperation()], - ); + : [createDefaultOperation()]; + setVisualMode('operations'); + setOperations(nextOperations); + setSelectedOperationId(nextOperations[0]?.id || ''); setLegacyValue(''); setJsonError(''); setEditMode('visual'); return; } if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const fallback = createDefaultOperation(); setVisualMode('legacy'); setLegacyValue(JSON.stringify(parsed, null, 2)); - setOperations([createDefaultOperation()]); + setOperations([fallback]); + setSelectedOperationId(fallback.id); setJsonError(''); setEditMode('visual'); return; @@ -587,27 +684,54 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { const setOldTemplate = () => { const text = JSON.stringify(LEGACY_TEMPLATE, null, 2); + const fallback = createDefaultOperation(); setVisualMode('legacy'); setLegacyValue(text); + setOperations([fallback]); + setSelectedOperationId(fallback.id); + setExpandedConditionMap({}); setJsonText(text); setJsonError(''); setEditMode('visual'); + setTemplateLibraryKey('legacy'); }; const setNewTemplate = () => { + const nextOperations = + OPERATION_TEMPLATE.operations.map(normalizeOperation); setVisualMode('operations'); - setOperations(OPERATION_TEMPLATE.operations.map(normalizeOperation)); + setOperations(nextOperations); + setSelectedOperationId(nextOperations[0]?.id || ''); + setExpandedConditionMap({}); setJsonText(JSON.stringify(OPERATION_TEMPLATE, null, 2)); setJsonError(''); setEditMode('visual'); + setTemplateLibraryKey('operations'); }; const clearValue = () => { + const fallback = createDefaultOperation(); setVisualMode('operations'); setLegacyValue(''); - setOperations([createDefaultOperation()]); + setOperations([fallback]); + setSelectedOperationId(fallback.id); + setExpandedConditionMap({}); setJsonText(''); setJsonError(''); + setTemplateLibraryKey('operations'); + }; + + const applyTemplateFromLibrary = () => { + if (templateLibraryKey === 'legacy') { + setOldTemplate(); + return; + } + setNewTemplate(); + }; + + const resetEditorState = () => { + clearValue(); + setEditMode('visual'); }; const updateOperation = (operationId, patch) => { @@ -619,10 +743,13 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { }; const addOperation = () => { - setOperations((prev) => [...prev, createDefaultOperation()]); + const created = createDefaultOperation(); + setOperations((prev) => [...prev, created]); + setSelectedOperationId(created.id); }; const duplicateOperation = (operationId) => { + let insertedId = ''; setOperations((prev) => { const index = prev.findIndex((item) => item.id === operationId); if (index < 0) return prev; @@ -643,10 +770,14 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { pass_missing_key: condition.pass_missing_key, })), }); + insertedId = cloned.id; const next = [...prev]; next.splice(index + 1, 0, cloned); return next; }); + if (insertedId) { + setSelectedOperationId(insertedId); + } }; const removeOperation = (operationId) => { @@ -654,19 +785,32 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { if (prev.length <= 1) return [createDefaultOperation()]; return prev.filter((item) => item.id !== operationId); }); + setExpandedConditionMap((prev) => { + if (!Object.prototype.hasOwnProperty.call(prev, operationId)) { + return prev; + } + const next = { ...prev }; + delete next[operationId]; + return next; + }); }; const addCondition = (operationId) => { + const createdCondition = createDefaultCondition(); setOperations((prev) => prev.map((operation) => operation.id === operationId ? { ...operation, - conditions: [...(operation.conditions || []), createDefaultCondition()], + conditions: [...(operation.conditions || []), createdCondition], } : operation, ), ); + setExpandedConditionMap((prev) => ({ + ...prev, + [operationId]: [...(prev[operationId] || []), createdCondition.id], + })); }; const updateCondition = (operationId, conditionId, patch) => { @@ -697,8 +841,50 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { }; }), ); + setExpandedConditionMap((prev) => ({ + ...prev, + [operationId]: (prev[operationId] || []).filter( + (id) => id !== conditionId, + ), + })); }; + const selectedConditionKeys = useMemo( + () => expandedConditionMap[selectedOperationId] || [], + [expandedConditionMap, selectedOperationId], + ); + + const handleConditionCollapseChange = useCallback( + (operationId, activeKeys) => { + const keys = ( + Array.isArray(activeKeys) ? activeKeys : [activeKeys] + ).filter(Boolean); + setExpandedConditionMap((prev) => ({ + ...prev, + [operationId]: keys, + })); + }, + [], + ); + + const expandAllSelectedConditions = useCallback(() => { + if (!selectedOperationId || !selectedOperation) return; + setExpandedConditionMap((prev) => ({ + ...prev, + [selectedOperationId]: (selectedOperation.conditions || []).map( + (condition) => condition.id, + ), + })); + }, [selectedOperation, selectedOperationId]); + + const collapseAllSelectedConditions = useCallback(() => { + if (!selectedOperationId) return; + setExpandedConditionMap((prev) => ({ + ...prev, + [selectedOperationId]: [], + })); + }, [selectedOperationId]); + const handleJsonChange = (nextValue) => { setJsonText(nextValue); const trimmed = String(nextValue || '').trim(); @@ -761,21 +947,13 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => { - - - - - + - updateOperation(operation.id, { mode: nextMode }) - } - style={{ width: '100%' }} - /> - - {meta.path || meta.pathOptional ? ( - - - {meta.pathOptional ? 'path (optional)' : 'path'} - - - updateOperation(operation.id, { - path: nextValue, - }) - } - /> - - {OPERATION_PATH_SUGGESTIONS.map((pathItem) => ( - - updateOperation(operation.id, { - path: pathItem, - }) - } - > - {pathItem} - - ))} - - - ) : null} - - - {MODE_DESCRIPTIONS[mode] || ''} - + + setOperationSearch(nextValue || '') + } + showClear + /> - {meta.value ? ( -
- - value (JSON or plain text) - - - updateOperation(operation.id, { - value_text: nextValue, - }) - } - /> -
- ) : null} - - {meta.keepOrigin ? ( -
- - updateOperation(operation.id, { - keep_origin: nextValue, - }) - } - /> - - keep_origin - -
- ) : null} - - {mode === 'sync_fields' ? ( -
- - sync endpoints - - - - - from endpoint - -
- - updateOperation(operation.id, { - from: buildSyncTargetSpec( - syncFromTarget?.type || 'json', - nextKey, - ), - }) - } - /> -
- - - - to endpoint - -
- - updateOperation(operation.id, { - to: buildSyncTargetSpec( - syncToTarget?.type || 'json', - nextKey, - ), - }) - } - /> -
- -
- - - updateOperation(operation.id, { - from: 'header:session_id', - to: 'json:prompt_cache_key', - }) - } - > - {'header:session_id -> json:prompt_cache_key'} - - - updateOperation(operation.id, { - from: 'json:prompt_cache_key', - to: 'header:session_id', - }) - } - > - {'json:prompt_cache_key -> header:session_id'} - - -
- ) : meta.from || meta.to === false || meta.to ? ( - - {meta.from || meta.to === false ? ( - - - from - - + {filteredOperations.length === 0 ? ( + + {t('没有匹配的规则')} + + ) : ( +
+ {filteredOperations.map((operation) => { + const index = operations.findIndex( + (item) => item.id === operation.id, + ); + const isActive = + operation.id === selectedOperationId; + return ( +
+ setSelectedOperationId(operation.id) } + onKeyDown={(event) => { + if ( + event.key === 'Enter' || + event.key === ' ' + ) { + event.preventDefault(); + setSelectedOperationId(operation.id); + } + }} + className='w-full rounded-xl px-3 py-3 cursor-pointer transition-colors' + style={{ + background: isActive + ? 'var(--semi-color-primary-light-default)' + : 'var(--semi-color-bg-2)', + border: isActive + ? '1px solid var(--semi-color-primary)' + : '1px solid var(--semi-color-border)', + }} + > +
+
+ {`#${index + 1}`} + + {getOperationSummary(operation, index)} + +
+ + {(operation.conditions || []).length} + +
+ + + {operation.mode || 'set'} + + + {t('条件')} + + +
+ ); + })} +
+ )} +
+ + + + {selectedOperation ? ( + (() => { + const mode = selectedOperation.mode || 'set'; + const meta = MODE_META[mode] || MODE_META.set; + const conditions = selectedOperation.conditions || []; + const syncFromTarget = + mode === 'sync_fields' + ? parseSyncTargetSpec(selectedOperation.from) + : null; + const syncToTarget = + mode === 'sync_fields' + ? parseSyncTargetSpec(selectedOperation.to) + : null; + return ( + +
+ + {`#${selectedOperationIndex + 1}`} + + {getOperationSummary( + selectedOperation, + selectedOperationIndex, + )} + + + + +
+ + + + + mode + + + updateOperation(selectedOperation.id, { + path: nextValue, + }) + } + /> + + {OPERATION_PATH_SUGGESTIONS.map( + (pathItem) => ( + + updateOperation( + selectedOperation.id, + { + path: pathItem, + }, + ) + } + > + {pathItem} + + ), + )} + + + ) : null} + + + + {MODE_DESCRIPTIONS[mode] || ''} + + + {meta.value ? ( +
+ + value (JSON or plain text) + +