diff --git a/controller/pricing.go b/controller/pricing.go index b6537e4..9d1191f 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -1,6 +1,7 @@ package controller import ( + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/ratio_setting" @@ -8,6 +9,30 @@ import ( "github.com/gin-gonic/gin" ) +func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing { + if len(pricing) == 0 { + return pricing + } + if len(usableGroup) == 0 { + return []model.Pricing{} + } + + filtered := make([]model.Pricing, 0, len(pricing)) + for _, item := range pricing { + if common.StringsContains(item.EnableGroup, "all") { + filtered = append(filtered, item) + continue + } + for _, group := range item.EnableGroup { + if _, ok := usableGroup[group]; ok { + filtered = append(filtered, item) + break + } + } + } + return filtered +} + func GetPricing(c *gin.Context) { pricing := model.GetPricing() userId, exists := c.Get("id") @@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) { } usableGroup = service.GetUserUsableGroups(group) + pricing = filterPricingByUsableGroups(pricing, usableGroup) // check groupRatio contains usableGroup for group := range ratio_setting.GetGroupRatioCopy() { if _, ok := usableGroup[group]; !ok { diff --git a/controller/region_sync.go b/controller/region_sync.go index 80bd904..cad5415 100644 --- a/controller/region_sync.go +++ b/controller/region_sync.go @@ -145,6 +145,41 @@ func QueryUserQuota(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true, "quota": user.Quota}) } +// BatchQueryUserQuota 批量查询用户余额(供 slave 节点调用) +func BatchQueryUserQuota(c *gin.Context) { + var req region_sync.BatchQueryQuotaRequest + if err := c.ShouldBindJSON(&req); err != nil { + syncErrorResponse(c, http.StatusBadRequest, "invalid request body") + return + } + + if len(req.UserIds) == 0 { + c.JSON(http.StatusOK, region_sync.BatchQueryQuotaResponse{Success: true, Quotas: []region_sync.QuotaEntry{}}) + return + } + + if len(req.UserIds) > 500 { + syncErrorResponse(c, http.StatusBadRequest, "too many user ids (max 500)") + return + } + + var users []model.User + if err := model.DB.Where("id IN ?", req.UserIds).Find(&users).Error; err != nil { + syncErrorResponse(c, http.StatusInternalServerError, "database error") + return + } + + quotas := make([]region_sync.QuotaEntry, 0, len(users)) + for _, user := range users { + quotas = append(quotas, region_sync.QuotaEntry{UserId: user.Id, Quota: user.Quota}) + } + + c.JSON(http.StatusOK, region_sync.BatchQueryQuotaResponse{ + Success: true, + Quotas: quotas, + }) +} + // BatchDeductQuota 批量扣费(供 slave 节点调用 master 节点) func BatchDeductQuota(c *gin.Context) { var req region_sync.BatchDeductRequest diff --git a/controller/video_proxy.go b/controller/video_proxy.go index f1dd2bc..fe8b3fc 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -8,10 +8,12 @@ import ( "net/url" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" ) @@ -109,6 +111,13 @@ func VideoProxy(c *gin.Context) { return } + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err)) + videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err)) + return + } + resp, err := client.Do(req) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error())) diff --git a/dto/gemini.go b/dto/gemini.go index b97f19e..cc1b502 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -121,6 +121,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool { if c.Query("alt") == "sse" { return true } + // Native Gemini API uses URL action to indicate streaming: + // /v1beta/models/{model}:streamGenerateContent + if strings.Contains(c.Request.URL.Path, "streamGenerateContent") { + return true + } return false } diff --git a/dto/openai_request.go b/dto/openai_request.go index c0a69a3..ef6dbed 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -387,7 +387,7 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl { type MessageImageUrl struct { Url string `json:"url"` - Detail string `json:"detail"` + Detail string `json:"detail,omitempty"` MimeType string } diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 069c784..be2e2a8 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -743,7 +743,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau if common.DebugEnabled { common.SysLog("claude response usage is not complete, maybe upstream error") } - claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) + // 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段 + fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + if claudeInfo.Usage.CompletionTokens == 0 || + (!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) { + claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens + } + if claudeInfo.Usage.PromptTokens == 0 { + claudeInfo.Usage.PromptTokens = fallback.PromptTokens + } + claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens } if info.RelayFormat == types.RelayFormatClaude { diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index f698fc9..5b6b01d 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -80,9 +80,9 @@ type AliVideoOutput struct { // AliUsage 使用统计 type AliUsage struct { - Duration int `json:"duration,omitempty"` - VideoCount int `json:"video_count,omitempty"` - SR int `json:"SR,omitempty"` + Duration dto.IntValue `json:"duration,omitempty"` + VideoCount dto.IntValue `json:"video_count,omitempty"` + SR dto.IntValue `json:"SR,omitempty"` } type AliMetadata struct { diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go index 597c485..6e547f6 100644 --- a/relay/channel/zhipu_4v/adaptor.go +++ b/relay/channel/zhipu_4v/adaptor.go @@ -63,6 +63,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil case relayconstant.RelayModeImagesGenerations: + if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { + return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil + } return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil default: if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 6412b7d..72c14c9 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, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 9b08781..eedd54d 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 types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } diff --git a/relay/common/override.go b/relay/common/override.go index 1a0c247..af0b436 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -1,18 +1,43 @@ package common import ( + "errors" "fmt" + "net/http" "regexp" + "sort" "strconv" "strings" "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" + paramOverrideContextHeaderOverride = "header_override" + paramOverrideContextAuditRecorder = "__param_override_audit_recorder" +) + +var errSourceHeaderNotFound = errors.New("source header does not exist") + +var paramOverrideKeyAuditPaths = map[string]struct{}{ + "model": {}, + "original_model": {}, + "upstream_model": {}, + "service_tier": {}, + "inference_geo": {}, +} + +type paramOverrideAuditRecorder struct { + lines []string +} + type ConditionOperation struct { Path string `json:"path"` // JSON路径 Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte @@ -23,7 +48,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, set_header, delete_header, copy_header, move_header, pass_headers, sync_fields Value interface{} `json:"value"` KeepOrigin bool `json:"keep_origin"` From string `json:"from,omitempty"` @@ -32,97 +57,444 @@ 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 } + auditRecorder := getParamOverrideAuditRecorder(conditionContext) // 尝试断言为操作格式 if operations, ok := tryParseOperations(paramOverride); ok { + legacyOverride := buildLegacyParamOverride(paramOverride) + workingJSON := jsonData + var err error + if len(legacyOverride) > 0 { + workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride, auditRecorder) + if err != nil { + return nil, err + } + } + // 使用新方法 - result, err := applyOperations(string(jsonData), operations, conditionContext) + result, err := applyOperations(string(workingJSON), operations, conditionContext) return []byte(result), err } // 直接使用旧方法 - return applyOperationsLegacy(jsonData, paramOverride) + return applyOperationsLegacy(jsonData, paramOverride, auditRecorder) +} + +func buildLegacyParamOverride(paramOverride map[string]interface{}) map[string]interface{} { + if len(paramOverride) == 0 { + return nil + } + legacy := make(map[string]interface{}, len(paramOverride)) + for key, value := range paramOverride { + if strings.EqualFold(strings.TrimSpace(key), "operations") { + continue + } + legacy[key] = value + } + return legacy +} + +func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte, error) { + paramOverride := getParamOverrideMap(info) + if len(paramOverride) == 0 { + return jsonData, nil + } + + overrideCtx := BuildParamOverrideContext(info) + var recorder *paramOverrideAuditRecorder + if shouldEnableParamOverrideAudit(paramOverride) { + recorder = ¶mOverrideAuditRecorder{} + overrideCtx[paramOverrideContextAuditRecorder] = recorder + } + result, err := ApplyParamOverride(jsonData, paramOverride, overrideCtx) + if err != nil { + return nil, err + } + syncRuntimeHeaderOverrideFromContext(info, overrideCtx) + if info != nil { + if recorder != nil { + info.ParamOverrideAudit = recorder.lines + } else { + info.ParamOverrideAudit = nil + } + } + return result, nil +} + +func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool { + if common.DebugEnabled { + return true + } + if len(paramOverride) == 0 { + return false + } + if operations, ok := tryParseOperations(paramOverride); ok { + for _, operation := range operations { + if shouldAuditParamPath(strings.TrimSpace(operation.Path)) || + shouldAuditParamPath(strings.TrimSpace(operation.To)) { + return true + } + } + for key := range buildLegacyParamOverride(paramOverride) { + if shouldAuditParamPath(strings.TrimSpace(key)) { + return true + } + } + return false + } + for key := range paramOverride { + if shouldAuditParamPath(strings.TrimSpace(key)) { + return true + } + } + return false +} + +func getParamOverrideAuditRecorder(context map[string]interface{}) *paramOverrideAuditRecorder { + if context == nil { + return nil + } + recorder, _ := context[paramOverrideContextAuditRecorder].(*paramOverrideAuditRecorder) + return recorder +} + +func (r *paramOverrideAuditRecorder) recordOperation(mode, path, from, to string, value interface{}) { + if r == nil { + return + } + line := buildParamOverrideAuditLine(mode, path, from, to, value) + if line == "" { + return + } + if lo.Contains(r.lines, line) { + return + } + r.lines = append(r.lines, line) +} + +func shouldAuditParamPath(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + if common.DebugEnabled { + return true + } + _, ok := paramOverrideKeyAuditPaths[path] + return ok +} + +func shouldAuditOperation(mode, path, from, to string) bool { + if common.DebugEnabled { + return true + } + for _, candidate := range []string{path, to} { + if shouldAuditParamPath(candidate) { + return true + } + } + return false +} + +func formatParamOverrideAuditValue(value interface{}) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + default: + return common.GetJsonString(typed) + } +} + +func buildParamOverrideAuditLine(mode, path, from, to string, value interface{}) string { + mode = strings.TrimSpace(mode) + path = strings.TrimSpace(path) + from = strings.TrimSpace(from) + to = strings.TrimSpace(to) + + if !shouldAuditOperation(mode, path, from, to) { + return "" + } + + switch mode { + case "set": + if path == "" { + return "" + } + return fmt.Sprintf("set %s = %s", path, formatParamOverrideAuditValue(value)) + case "delete": + if path == "" { + return "" + } + return fmt.Sprintf("delete %s", path) + case "copy": + if from == "" || to == "" { + return "" + } + return fmt.Sprintf("copy %s -> %s", from, to) + case "move": + if from == "" || to == "" { + return "" + } + return fmt.Sprintf("move %s -> %s", from, to) + case "prepend": + if path == "" { + return "" + } + return fmt.Sprintf("prepend %s with %s", path, formatParamOverrideAuditValue(value)) + case "append": + if path == "" { + return "" + } + return fmt.Sprintf("append %s with %s", path, formatParamOverrideAuditValue(value)) + case "trim_prefix", "trim_suffix", "ensure_prefix", "ensure_suffix": + if path == "" { + return "" + } + return fmt.Sprintf("%s %s with %s", mode, path, formatParamOverrideAuditValue(value)) + case "trim_space", "to_lower", "to_upper": + if path == "" { + return "" + } + return fmt.Sprintf("%s %s", mode, path) + case "replace", "regex_replace": + if path == "" { + return "" + } + return fmt.Sprintf("%s %s from %s to %s", mode, path, from, to) + case "set_header": + if path == "" { + return "" + } + return fmt.Sprintf("set_header %s = %s", path, formatParamOverrideAuditValue(value)) + case "delete_header": + if path == "" { + return "" + } + return fmt.Sprintf("delete_header %s", path) + case "copy_header", "move_header": + if from == "" || to == "" { + return "" + } + return fmt.Sprintf("%s %s -> %s", mode, from, to) + case "pass_headers": + return fmt.Sprintf("pass_headers %s", formatParamOverrideAuditValue(value)) + case "sync_fields": + if from == "" || to == "" { + return "" + } + return fmt.Sprintf("sync_fields %s -> %s", from, to) + case "return_error": + return fmt.Sprintf("return_error %s", formatParamOverrideAuditValue(value)) + default: + if path == "" { + return mode + } + return fmt.Sprintf("%s %s", mode, path) + } +} + +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 sanitizeHeaderOverrideMap(source map[string]interface{}) map[string]interface{} { + if len(source) == 0 { + return map[string]interface{}{} + } + target := make(map[string]interface{}, len(source)) + for key, value := range source { + normalizedKey := normalizeHeaderContextKey(key) + if normalizedKey == "" { + continue + } + normalizedValue := strings.TrimSpace(fmt.Sprintf("%v", value)) + if normalizedValue == "" { + if isHeaderPassthroughRuleKeyForOverride(normalizedKey) { + target[normalizedKey] = "" + } + continue + } + target[normalizedKey] = normalizedValue + } + return target +} + +func isHeaderPassthroughRuleKeyForOverride(key string) bool { + key = strings.TrimSpace(strings.ToLower(key)) + if key == "" { + return false + } + if key == "*" { + return true + } + return strings.HasPrefix(key, "re:") || strings.HasPrefix(key, "regex:") +} + +func GetEffectiveHeaderOverride(info *RelayInfo) map[string]interface{} { + if info == nil { + return map[string]interface{}{} + } + if info.UseRuntimeHeadersOverride { + return sanitizeHeaderOverrideMap(info.RuntimeHeadersOverride) + } + return sanitizeHeaderOverrideMap(getHeaderOverrideMap(info)) } func tryParseOperations(paramOverride map[string]interface{}) ([]ParamOperation, bool) { // 检查是否包含 "operations" 字段 - if opsValue, exists := paramOverride["operations"]; exists { - if opsSlice, ok := opsValue.([]interface{}); ok { - var operations []ParamOperation - for _, op := range opsSlice { - if opMap, ok := op.(map[string]interface{}); ok { - operation := ParamOperation{} - - // 断言必要字段 - if path, ok := opMap["path"].(string); ok { - operation.Path = path - } - if mode, ok := opMap["mode"].(string); ok { - operation.Mode = mode - } else { - return nil, false // mode 是必需的 - } + opsValue, exists := paramOverride["operations"] + if !exists { + return nil, false + } - // 可选字段 - if value, exists := opMap["value"]; exists { - operation.Value = value - } - if keepOrigin, ok := opMap["keep_origin"].(bool); ok { - operation.KeepOrigin = keepOrigin - } - if from, ok := opMap["from"].(string); ok { - operation.From = from - } - if to, ok := opMap["to"].(string); ok { - operation.To = to - } - if logic, ok := opMap["logic"].(string); ok { - operation.Logic = logic - } else { - operation.Logic = "OR" // 默认为OR - } + var opMaps []map[string]interface{} + switch ops := opsValue.(type) { + case []interface{}: + opMaps = make([]map[string]interface{}, 0, len(ops)) + for _, op := range ops { + opMap, ok := op.(map[string]interface{}) + if !ok { + return nil, false + } + opMaps = append(opMaps, opMap) + } + case []map[string]interface{}: + opMaps = ops + default: + return nil, false + } - // 解析条件 - 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) - } - } - } - } + operations := make([]ParamOperation, 0, len(opMaps)) + for _, opMap := range opMaps { + operation := ParamOperation{} - operations = append(operations, operation) - } else { - return nil, false - } + // 断言必要字段 + if path, ok := opMap["path"].(string); ok { + operation.Path = path + } + if mode, ok := opMap["mode"].(string); ok { + operation.Mode = mode + } else { + return nil, false // mode 是必需的 + } + + // 可选字段 + if value, exists := opMap["value"]; exists { + operation.Value = value + } + if keepOrigin, ok := opMap["keep_origin"].(bool); ok { + operation.KeepOrigin = keepOrigin + } + if from, ok := opMap["from"].(string); ok { + operation.From = from + } + if to, ok := opMap["to"].(string); ok { + operation.To = to + } + if logic, ok := opMap["logic"].(string); ok { + operation.Logic = logic + } else { + operation.Logic = "OR" // 默认为OR + } + + // 解析条件 + if conditions, exists := opMap["conditions"]; exists { + parsedConditions, err := parseConditionOperations(conditions) + if err != nil { + return nil, false } - return operations, true + operation.Conditions = append(operation.Conditions, parsedConditions...) } - } - return nil, false + operations = append(operations, operation) + } + return operations, true } func checkConditions(jsonStr, contextJSON string, conditions []ConditionOperation, logic string) (bool, error) { @@ -139,20 +511,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) { @@ -294,7 +655,7 @@ func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool, } // applyOperationsLegacy 原参数覆盖方法 -func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}) ([]byte, error) { +func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}, auditRecorder *paramOverrideAuditRecorder) ([]byte, error) { reqMap := make(map[string]interface{}) err := common.Unmarshal(jsonData, &reqMap) if err != nil { @@ -303,19 +664,18 @@ func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{} for key, value := range paramOverride { reqMap[key] = value + auditRecorder.recordOperation("set", key, "", "", value) } return common.Marshal(reqMap) } 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) + auditRecorder := getParamOverrideAuditRecorder(context) + contextJSON, err := marshalContextJSON(context) + if err != nil { + return "", fmt.Errorf("failed to marshal condition context: %v", err) } result := jsonStr @@ -330,19 +690,44 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte } // 处理路径中的负数索引 opPath := processNegativeIndex(result, op.Path) + var opPaths []string + if isPathBasedOperation(op.Mode) { + opPaths, err = resolveOperationPaths(result, opPath) + if err != nil { + return "", err + } + if len(opPaths) == 0 { + continue + } + } switch op.Mode { case "delete": - result, err = sjson.Delete(result, opPath) + for _, path := range opPaths { + result, err = deleteValue(result, path) + if err != nil { + break + } + auditRecorder.recordOperation("delete", path, "", "", nil) + } case "set": - if op.KeepOrigin && gjson.Get(result, opPath).Exists() { - continue + for _, path := range opPaths { + if op.KeepOrigin && gjson.Get(result, path).Exists() { + continue + } + result, err = sjson.Set(result, path, op.Value) + if err != nil { + break + } + auditRecorder.recordOperation("set", path, "", "", op.Value) } - result, err = sjson.Set(result, opPath, op.Value) case "move": opFrom := processNegativeIndex(result, op.From) opTo := processNegativeIndex(result, op.To) result, err = moveValue(result, opFrom, opTo) + if err == nil { + auditRecorder.recordOperation("move", "", opFrom, opTo, nil) + } case "copy": if op.From == "" || op.To == "" { return "", fmt.Errorf("copy from/to is required") @@ -350,64 +735,873 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte opFrom := processNegativeIndex(result, op.From) opTo := processNegativeIndex(result, op.To) result, err = copyValue(result, opFrom, opTo) + if err == nil { + auditRecorder.recordOperation("copy", "", opFrom, opTo, nil) + } case "prepend": - result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true) + for _, path := range opPaths { + result, err = modifyValue(result, path, op.Value, op.KeepOrigin, true) + if err != nil { + break + } + auditRecorder.recordOperation("prepend", path, "", "", op.Value) + } case "append": - result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false) + for _, path := range opPaths { + result, err = modifyValue(result, path, op.Value, op.KeepOrigin, false) + if err != nil { + break + } + auditRecorder.recordOperation("append", path, "", "", op.Value) + } case "trim_prefix": - result, err = trimStringValue(result, opPath, op.Value, true) + for _, path := range opPaths { + result, err = trimStringValue(result, path, op.Value, true) + if err != nil { + break + } + auditRecorder.recordOperation("trim_prefix", path, "", "", op.Value) + } case "trim_suffix": - result, err = trimStringValue(result, opPath, op.Value, false) + for _, path := range opPaths { + result, err = trimStringValue(result, path, op.Value, false) + if err != nil { + break + } + auditRecorder.recordOperation("trim_suffix", path, "", "", op.Value) + } case "ensure_prefix": - result, err = ensureStringAffix(result, opPath, op.Value, true) + for _, path := range opPaths { + result, err = ensureStringAffix(result, path, op.Value, true) + if err != nil { + break + } + auditRecorder.recordOperation("ensure_prefix", path, "", "", op.Value) + } case "ensure_suffix": - result, err = ensureStringAffix(result, opPath, op.Value, false) + for _, path := range opPaths { + result, err = ensureStringAffix(result, path, op.Value, false) + if err != nil { + break + } + auditRecorder.recordOperation("ensure_suffix", path, "", "", op.Value) + } case "trim_space": - result, err = transformStringValue(result, opPath, strings.TrimSpace) + for _, path := range opPaths { + result, err = transformStringValue(result, path, strings.TrimSpace) + if err != nil { + break + } + auditRecorder.recordOperation("trim_space", path, "", "", nil) + } case "to_lower": - result, err = transformStringValue(result, opPath, strings.ToLower) + for _, path := range opPaths { + result, err = transformStringValue(result, path, strings.ToLower) + if err != nil { + break + } + auditRecorder.recordOperation("to_lower", path, "", "", nil) + } case "to_upper": - result, err = transformStringValue(result, opPath, strings.ToUpper) + for _, path := range opPaths { + result, err = transformStringValue(result, path, strings.ToUpper) + if err != nil { + break + } + auditRecorder.recordOperation("to_upper", path, "", "", nil) + } case "replace": - result, err = replaceStringValue(result, opPath, op.From, op.To) + for _, path := range opPaths { + result, err = replaceStringValue(result, path, op.From, op.To) + if err != nil { + break + } + auditRecorder.recordOperation("replace", path, op.From, op.To, nil) + } case "regex_replace": - result, err = regexReplaceStringValue(result, opPath, op.From, op.To) + for _, path := range opPaths { + result, err = regexReplaceStringValue(result, path, op.From, op.To) + if err != nil { + break + } + auditRecorder.recordOperation("regex_replace", path, op.From, op.To, nil) + } + case "return_error": + auditRecorder.recordOperation("return_error", op.Path, "", "", op.Value) + returnErr, parseErr := parseParamOverrideReturnError(op.Value) + if parseErr != nil { + return "", parseErr + } + return "", returnErr + case "prune_objects": + for _, path := range opPaths { + result, err = pruneObjects(result, path, contextJSON, op.Value) + if err != nil { + break + } + } + case "set_header": + err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin) + if err == nil { + auditRecorder.recordOperation("set_header", op.Path, "", "", op.Value) + contextJSON, err = marshalContextJSON(context) + } + case "delete_header": + err = deleteHeaderOverrideInContext(context, op.Path) + if err == nil { + auditRecorder.recordOperation("delete_header", op.Path, "", "", 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 errors.Is(err, errSourceHeaderNotFound) { + err = nil + } + if err == nil { + auditRecorder.recordOperation("copy_header", "", sourceHeader, targetHeader, 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 errors.Is(err, errSourceHeaderNotFound) { + err = nil + } + if err == nil { + auditRecorder.recordOperation("move_header", "", sourceHeader, targetHeader, nil) + contextJSON, err = marshalContextJSON(context) + } + case "pass_headers": + headerNames, parseErr := parseHeaderPassThroughNames(op.Value) + if parseErr != nil { + return "", parseErr + } + for _, headerName := range headerNames { + if err = copyHeaderInContext(context, headerName, headerName, op.KeepOrigin); err != nil { + if errors.Is(err, errSourceHeaderNotFound) { + err = nil + continue + } + break + } + } + if err == nil { + auditRecorder.recordOperation("pass_headers", "", "", "", headerNames) + contextJSON, err = marshalContextJSON(context) + } + case "sync_fields": + result, err = syncFieldsBetweenTargets(result, context, op.From, op.To) + if err == nil { + auditRecorder.recordOperation("sync_fields", "", op.From, op.To, nil) + contextJSON, err = marshalContextJSON(context) + } 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 moveValue(jsonStr, fromPath, toPath string) (string, error) { - sourceValue := gjson.Get(jsonStr, fromPath) - if !sourceValue.Exists() { - return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath) - } - result, err := sjson.Set(jsonStr, toPath, sourceValue.Value()) - if err != nil { - return "", err +func parseParamOverrideReturnError(value interface{}) (*ParamOverrideReturnError, error) { + result := &ParamOverrideReturnError{ + StatusCode: http.StatusBadRequest, + Code: string(types.ErrorCodeInvalidRequest), + Type: "invalid_request_error", + SkipRetry: true, } - return sjson.Delete(result, fromPath) -} -func copyValue(jsonStr, fromPath, toPath string) (string, error) { - sourceValue := gjson.Get(jsonStr, fromPath) - if !sourceValue.Exists() { - return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath) - } - return sjson.Set(jsonStr, toPath, sourceValue.Value()) -} + 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) + } + } -func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) { - current := gjson.Get(jsonStr, path) - switch { - case current.IsArray(): - return modifyArray(jsonStr, path, value, isPrepend) - case current.Type == gjson.String: + 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 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 = normalizeHeaderContextKey(headerName) + if headerName == "" { + return fmt.Errorf("header name is required") + } + + rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride) + if keepOrigin { + if existing, ok := rawHeaders[headerName]; ok { + existingValue := strings.TrimSpace(fmt.Sprintf("%v", existing)) + if existingValue != "" { + return nil + } + } + } + + headerValue, hasValue, err := resolveHeaderOverrideValue(context, headerName, value) + if err != nil { + return err + } + if !hasValue { + delete(rawHeaders, headerName) + return nil + } + + rawHeaders[headerName] = headerValue + return nil +} + +func resolveHeaderOverrideValue(context map[string]interface{}, headerName string, value interface{}) (string, bool, error) { + if value == nil { + return "", false, fmt.Errorf("header value is required") + } + + if mapping, ok := value.(map[string]interface{}); ok { + return resolveHeaderOverrideValueByMapping(context, headerName, mapping) + } + if mapping, ok := value.(map[string]string); ok { + converted := make(map[string]interface{}, len(mapping)) + for key, item := range mapping { + converted[key] = item + } + return resolveHeaderOverrideValueByMapping(context, headerName, converted) + } + + headerValue := strings.TrimSpace(fmt.Sprintf("%v", value)) + if headerValue == "" { + return "", false, nil + } + return headerValue, true, nil +} + +func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerName string, mapping map[string]interface{}) (string, bool, error) { + if len(mapping) == 0 { + return "", false, fmt.Errorf("header value mapping cannot be empty") + } + + appendTokens, err := parseHeaderAppendTokens(mapping) + if err != nil { + return "", false, err + } + keepOnlyDeclared := parseHeaderKeepOnlyDeclared(mapping) + + sourceValue, exists := getHeaderValueFromContext(context, headerName) + sourceTokens := make([]string, 0) + if exists { + sourceTokens = splitHeaderListValue(sourceValue) + } + + wildcardValue, hasWildcard := mapping["*"] + resultTokens := make([]string, 0, len(sourceTokens)+len(appendTokens)) + for _, token := range sourceTokens { + replacementRaw, hasReplacement := mapping[token] + if !hasReplacement && hasWildcard && !keepOnlyDeclared { + replacementRaw = wildcardValue + hasReplacement = true + } + if !hasReplacement { + if keepOnlyDeclared { + continue + } + resultTokens = append(resultTokens, token) + continue + } + replacementTokens, err := parseHeaderReplacementTokens(replacementRaw) + if err != nil { + return "", false, err + } + resultTokens = append(resultTokens, replacementTokens...) + } + + resultTokens = append(resultTokens, appendTokens...) + resultTokens = lo.Uniq(resultTokens) + if len(resultTokens) == 0 { + return "", false, nil + } + return strings.Join(resultTokens, ","), true, nil +} + +func parseHeaderAppendTokens(mapping map[string]interface{}) ([]string, error) { + appendRaw, ok := mapping["$append"] + if !ok { + return nil, nil + } + return parseHeaderReplacementTokens(appendRaw) +} + +func parseHeaderKeepOnlyDeclared(mapping map[string]interface{}) bool { + keepOnlyDeclaredRaw, ok := mapping["$keep_only_declared"] + if !ok { + return false + } + keepOnlyDeclared, ok := keepOnlyDeclaredRaw.(bool) + if !ok { + return false + } + return keepOnlyDeclared +} + +func parseHeaderReplacementTokens(value interface{}) ([]string, error) { + switch raw := value.(type) { + case nil: + return nil, nil + case string: + return splitHeaderListValue(raw), nil + case []string: + tokens := make([]string, 0, len(raw)) + for _, item := range raw { + tokens = append(tokens, splitHeaderListValue(item)...) + } + return lo.Uniq(tokens), nil + case []interface{}: + tokens := make([]string, 0, len(raw)) + for _, item := range raw { + itemTokens, err := parseHeaderReplacementTokens(item) + if err != nil { + return nil, err + } + tokens = append(tokens, itemTokens...) + } + return lo.Uniq(tokens), nil + case map[string]interface{}, map[string]string: + return nil, fmt.Errorf("header replacement value must be string, array or null") + default: + token := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if token == "" { + return nil, nil + } + return []string{token}, nil + } +} + +func splitHeaderListValue(raw string) []string { + items := strings.Split(raw, ",") + return lo.FilterMap(items, func(item string, _ int) (string, bool) { + token := strings.TrimSpace(item) + if token == "" { + return "", false + } + return token, true + }) +} + +func copyHeaderInContext(context map[string]interface{}, fromHeader, toHeader string, keepOrigin bool) error { + fromHeader = normalizeHeaderContextKey(fromHeader) + toHeader = normalizeHeaderContextKey(toHeader) + if fromHeader == "" || toHeader == "" { + return fmt.Errorf("copy_header from/to is required") + } + value, exists := getHeaderValueFromContext(context, fromHeader) + if !exists { + return fmt.Errorf("%w: %s", errSourceHeaderNotFound, fromHeader) + } + return setHeaderOverrideInContext(context, toHeader, value, keepOrigin) +} + +func moveHeaderInContext(context map[string]interface{}, fromHeader, toHeader string, keepOrigin bool) error { + fromHeader = normalizeHeaderContextKey(fromHeader) + toHeader = normalizeHeaderContextKey(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 = normalizeHeaderContextKey(headerName) + if headerName == "" { + return fmt.Errorf("header name is required") + } + rawHeaders := ensureMapKeyInContext(context, paramOverrideContextHeaderOverride) + delete(rawHeaders, headerName) + return nil +} + +func parseHeaderPassThroughNames(value interface{}) ([]string, error) { + normalizeNames := func(values []string) []string { + names := lo.FilterMap(values, func(item string, _ int) (string, bool) { + headerName := normalizeHeaderContextKey(item) + if headerName == "" { + return "", false + } + return headerName, true + }) + return lo.Uniq(names) + } + + switch raw := value.(type) { + case nil: + return nil, fmt.Errorf("pass_headers value is required") + case string: + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("pass_headers value is required") + } + if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") { + var parsed interface{} + if err := common.UnmarshalJsonStr(trimmed, &parsed); err == nil { + return parseHeaderPassThroughNames(parsed) + } + } + names := normalizeNames(strings.Split(trimmed, ",")) + if len(names) == 0 { + return nil, fmt.Errorf("pass_headers value is invalid") + } + return names, nil + case []interface{}: + names := lo.FilterMap(raw, func(item interface{}, _ int) (string, bool) { + headerName := normalizeHeaderContextKey(fmt.Sprintf("%v", item)) + if headerName == "" { + return "", false + } + return headerName, true + }) + names = lo.Uniq(names) + if len(names) == 0 { + return nil, fmt.Errorf("pass_headers value is invalid") + } + return names, nil + case []string: + names := lo.FilterMap(raw, func(item string, _ int) (string, bool) { + headerName := normalizeHeaderContextKey(item) + if headerName == "" { + return "", false + } + return headerName, true + }) + names = lo.Uniq(names) + if len(names) == 0 { + return nil, fmt.Errorf("pass_headers value is invalid") + } + return names, nil + case map[string]interface{}: + candidates := make([]string, 0, 8) + if headersRaw, ok := raw["headers"]; ok { + names, err := parseHeaderPassThroughNames(headersRaw) + if err == nil { + candidates = append(candidates, names...) + } + } + if namesRaw, ok := raw["names"]; ok { + names, err := parseHeaderPassThroughNames(namesRaw) + if err == nil { + candidates = append(candidates, names...) + } + } + if headerRaw, ok := raw["header"]; ok { + names, err := parseHeaderPassThroughNames(headerRaw) + if err == nil { + candidates = append(candidates, names...) + } + } + names := normalizeNames(candidates) + if len(names) == 0 { + return nil, fmt.Errorf("pass_headers value is invalid") + } + return names, nil + default: + return nil, fmt.Errorf("pass_headers value must be string, array or object") + } +} + +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{}{} + } + 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 = normalizeHeaderContextKey(headerName) + if headerName == "" { + return "", false + } + for _, key := range []string{paramOverrideContextHeaderOverride, paramOverrideContextRequestHeaders} { + source := ensureMapKeyInContext(context, key) + raw, ok := source[headerName] + if !ok { + continue + } + value := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if value != "" { + return value, true + } + } + return "", false +} + +func normalizeHeaderContextKey(key string) string { + return strings.TrimSpace(strings.ToLower(key)) +} + +func buildRequestHeadersContext(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 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 + } + info.RuntimeHeadersOverride = sanitizeHeaderOverrideMap(rawMap) + info.UseRuntimeHeadersOverride = true +} + +func moveValue(jsonStr, fromPath, toPath string) (string, error) { + sourceValue := gjson.Get(jsonStr, fromPath) + if !sourceValue.Exists() { + return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath) + } + result, err := sjson.Set(jsonStr, toPath, sourceValue.Value()) + if err != nil { + return "", err + } + return sjson.Delete(result, fromPath) +} + +func copyValue(jsonStr, fromPath, toPath string) (string, error) { + sourceValue := gjson.Get(jsonStr, fromPath) + if !sourceValue.Exists() { + return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath) + } + return sjson.Set(jsonStr, toPath, sourceValue.Value()) +} + +func isPathBasedOperation(mode string) bool { + switch mode { + case "delete", "set", "prepend", "append", "trim_prefix", "trim_suffix", "ensure_prefix", "ensure_suffix", "trim_space", "to_lower", "to_upper", "replace", "regex_replace", "prune_objects": + return true + default: + return false + } +} + +func resolveOperationPaths(jsonStr, path string) ([]string, error) { + if !strings.Contains(path, "*") { + return []string{path}, nil + } + return expandWildcardPaths(jsonStr, path) +} + +func expandWildcardPaths(jsonStr, path string) ([]string, error) { + var root interface{} + if err := common.Unmarshal([]byte(jsonStr), &root); err != nil { + return nil, err + } + + segments := strings.Split(path, ".") + paths := collectWildcardPaths(root, segments, nil) + return lo.Uniq(paths), nil +} + +func collectWildcardPaths(node interface{}, segments []string, prefix []string) []string { + if len(segments) == 0 { + return []string{strings.Join(prefix, ".")} + } + + segment := strings.TrimSpace(segments[0]) + if segment == "" { + return nil + } + isLast := len(segments) == 1 + + if segment == "*" { + switch typed := node.(type) { + case map[string]interface{}: + keys := lo.Keys(typed) + sort.Strings(keys) + return lo.FlatMap(keys, func(key string, _ int) []string { + return collectWildcardPaths(typed[key], segments[1:], append(prefix, key)) + }) + case []interface{}: + return lo.FlatMap(lo.Range(len(typed)), func(index int, _ int) []string { + return collectWildcardPaths(typed[index], segments[1:], append(prefix, strconv.Itoa(index))) + }) + default: + return nil + } + } + + switch typed := node.(type) { + case map[string]interface{}: + if isLast { + return []string{strings.Join(append(prefix, segment), ".")} + } + next, exists := typed[segment] + if !exists { + return nil + } + return collectWildcardPaths(next, segments[1:], append(prefix, segment)) + case []interface{}: + index, err := strconv.Atoi(segment) + if err != nil || index < 0 || index >= len(typed) { + return nil + } + if isLast { + return []string{strings.Join(append(prefix, segment), ".")} + } + return collectWildcardPaths(typed[index], segments[1:], append(prefix, segment)) + default: + return nil + } +} + +func deleteValue(jsonStr, path string) (string, error) { + if strings.TrimSpace(path) == "" { + return jsonStr, nil + } + return sjson.Delete(jsonStr, path) +} + +func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) { + current := gjson.Get(jsonStr, path) + switch { + case current.IsArray(): + return modifyArray(jsonStr, path, value, isPrepend) + case current.Type == gjson.String: return modifyString(jsonStr, path, value, isPrepend) case current.Type == gjson.JSON: return mergeObjects(jsonStr, path, value, keepOrigin) @@ -537,6 +1731,235 @@ 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) { + 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") + } +} + +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 +2021,37 @@ func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { } } + ctx[paramOverrideContextRequestHeaders] = buildRequestHeadersContext(info.RequestHeaders) + + headerOverrideSource := GetEffectiveHeaderOverride(info) + ctx[paramOverrideContextHeaderOverride] = sanitizeHeaderOverrideMap(headerOverrideSource) + + 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 c83cddf..1a7793b 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -2,11 +2,16 @@ package common import ( "encoding/json" + "fmt" "reflect" "testing" + common2 "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/samber/lo" ) func TestApplyParamOverrideTrimPrefix(t *testing.T) { @@ -72,6 +77,48 @@ func TestApplyParamOverrideTrimNoop(t *testing.T) { assertJSONEqual(t, `{"model":"gpt-4","temperature":0.7}`, string(out)) } +func TestApplyParamOverrideMixedLegacyAndOperations(t *testing.T) { + input := []byte(`{"model":"openai/gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "temperature": 0.2, + "top_p": 0.95, + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "trim_prefix", + "value": "openai/", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.2,"top_p":0.95}`, string(out)) +} + +func TestApplyParamOverrideMixedLegacyAndOperationsConflictPrefersOperations(t *testing.T) { + input := []byte(`{"model":"openai/gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "model": "legacy-model", + "temperature": 0.2, + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "set", + "value": "op-model", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"op-model","temperature":0.2}`, string(out)) +} + func TestApplyParamOverrideTrimRequiresValue(t *testing.T) { // trim_prefix requires value example: // {"operations":[{"path":"model","mode":"trim_prefix"}]} @@ -198,6 +245,224 @@ func TestApplyParamOverrideDelete(t *testing.T) { } } +func TestApplyParamOverrideDeleteWildcardPath(t *testing.T) { + input := []byte(`{"tools":[{"type":"bash","custom":{"input_examples":["a"],"other":1}},{"type":"code","custom":{"input_examples":["b"]}},{"type":"noop","custom":{"other":2}}]}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "tools.*.custom.input_examples", + "mode": "delete", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"tools":[{"type":"bash","custom":{"other":1}},{"type":"code","custom":{}},{"type":"noop","custom":{"other":2}}]}`, string(out)) +} + +func TestApplyParamOverrideSetWildcardPath(t *testing.T) { + input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B"}},{"custom":{"tag":"C"}}]}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "tools.*.custom.enabled", + "mode": "set", + "value": true, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + var got struct { + Tools []struct { + Custom struct { + Enabled bool `json:"enabled"` + } `json:"custom"` + } `json:"tools"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("failed to unmarshal output JSON: %v", err) + } + + if !lo.EveryBy(got.Tools, func(item struct { + Custom struct { + Enabled bool `json:"enabled"` + } `json:"custom"` + }) bool { + return item.Custom.Enabled + }) { + t.Fatalf("expected wildcard set to enable all tools, got: %s", string(out)) + } +} + +func TestApplyParamOverrideTrimSpaceWildcardPath(t *testing.T) { + input := []byte(`{"tools":[{"custom":{"name":" alpha "}},{"custom":{"name":" beta"}},{"custom":{"name":"gamma "}}]}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "tools.*.custom.name", + "mode": "trim_space", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + var got struct { + Tools []struct { + Custom struct { + Name string `json:"name"` + } `json:"custom"` + } `json:"tools"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("failed to unmarshal output JSON: %v", err) + } + + names := lo.Map(got.Tools, func(item struct { + Custom struct { + Name string `json:"name"` + } `json:"custom"` + }, _ int) string { + return item.Custom.Name + }) + if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) { + t.Fatalf("unexpected names after wildcard trim_space: %v", names) + } +} + +func TestApplyParamOverrideDeleteWildcardEqualsIndexedPaths(t *testing.T) { + input := []byte(`{"tools":[{"custom":{"input_examples":["a"],"other":1}},{"custom":{"input_examples":["b"],"other":2}},{"custom":{"input_examples":["c"],"other":3}}]}`) + + wildcardOverride := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "tools.*.custom.input_examples", + "mode": "delete", + }, + }, + } + + indexedOverride := map[string]interface{}{ + "operations": lo.Map(lo.Range(3), func(index int, _ int) interface{} { + return map[string]interface{}{ + "path": fmt.Sprintf("tools.%d.custom.input_examples", index), + "mode": "delete", + } + }), + } + + wildcardOut, err := ApplyParamOverride(input, wildcardOverride, nil) + if err != nil { + t.Fatalf("wildcard ApplyParamOverride returned error: %v", err) + } + + indexedOut, err := ApplyParamOverride(input, indexedOverride, nil) + if err != nil { + t.Fatalf("indexed ApplyParamOverride returned error: %v", err) + } + + assertJSONEqual(t, string(indexedOut), string(wildcardOut)) +} + +func TestApplyParamOverrideSetWildcardKeepOrigin(t *testing.T) { + input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B","enabled":false}},{"custom":{"tag":"C"}}]}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "tools.*.custom.enabled", + "mode": "set", + "value": true, + "keep_origin": true, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + var got struct { + Tools []struct { + Custom struct { + Enabled bool `json:"enabled"` + } `json:"custom"` + } `json:"tools"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("failed to unmarshal output JSON: %v", err) + } + + enabledValues := lo.Map(got.Tools, func(item struct { + Custom struct { + Enabled bool `json:"enabled"` + } `json:"custom"` + }, _ int) bool { + return item.Custom.Enabled + }) + if !reflect.DeepEqual(enabledValues, []bool{true, false, true}) { + t.Fatalf("unexpected enabled values after wildcard keep_origin set: %v", enabledValues) + } +} + +func TestApplyParamOverrideTrimSpaceMultiWildcardPath(t *testing.T) { + input := []byte(`{"tools":[{"custom":{"items":[{"name":" alpha "},{"name":" beta "}]}},{"custom":{"items":[{"name":" gamma"}]}}]}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "tools.*.custom.items.*.name", + "mode": "trim_space", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + var got struct { + Tools []struct { + Custom struct { + Items []struct { + Name string `json:"name"` + } `json:"items"` + } `json:"custom"` + } `json:"tools"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("failed to unmarshal output JSON: %v", err) + } + + names := lo.FlatMap(got.Tools, func(tool struct { + Custom struct { + Items []struct { + Name string `json:"name"` + } `json:"items"` + } `json:"custom"` + }, _ int) []string { + return lo.Map(tool.Custom.Items, func(item struct { + Name string `json:"name"` + }, _ int) string { + return item.Name + }) + }) + if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) { + t.Fatalf("unexpected names after multi wildcard trim_space: %v", names) + } +} + func TestApplyParamOverrideSet(t *testing.T) { input := []byte(`{"model":"gpt-4","temperature":0.7}`) override := map[string]interface{}{ @@ -217,6 +482,42 @@ func TestApplyParamOverrideSet(t *testing.T) { assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out)) } +func TestApplyParamOverrideSetWithDescriptionKeepsCompatibility(t *testing.T) { + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + overrideWithoutDesc := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + }, + }, + } + overrideWithDesc := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "description": "set temperature for deterministic output", + "path": "temperature", + "mode": "set", + "value": 0.1, + }, + }, + } + + outWithoutDesc, err := ApplyParamOverride(input, overrideWithoutDesc, nil) + if err != nil { + t.Fatalf("ApplyParamOverride without description returned error: %v", err) + } + + outWithDesc, err := ApplyParamOverride(input, overrideWithDesc, nil) + if err != nil { + t.Fatalf("ApplyParamOverride with description returned error: %v", err) + } + + assertJSONEqual(t, string(outWithoutDesc), string(outWithDesc)) + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(outWithDesc)) +} + func TestApplyParamOverrideSetKeepOrigin(t *testing.T) { input := []byte(`{"model":"gpt-4","temperature":0.7}`) override := map[string]interface{}{ @@ -775,63 +1076,984 @@ func TestApplyParamOverrideToUpper(t *testing.T) { assertJSONEqual(t, `{"model":"GPT-4"}`, string(out)) } -func TestRemoveDisabledFieldsSkipWhenChannelPassThroughEnabled(t *testing.T) { - input := `{ - "service_tier":"flex", - "safety_identifier":"user-123", - "store":true, - "stream_options":{"include_obfuscation":false} - }` - settings := dto.ChannelOtherSettings{} - - out, err := RemoveDisabledFields([]byte(input), settings, true) - if err != nil { - t.Fatalf("RemoveDisabledFields returned error: %v", err) +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, + }, } - assertJSONEqual(t, input, string(out)) -} - -func TestRemoveDisabledFieldsSkipWhenGlobalPassThroughEnabled(t *testing.T) { - original := model_setting.GetGlobalSettings().PassThroughRequestEnabled - model_setting.GetGlobalSettings().PassThroughRequestEnabled = true - t.Cleanup(func() { - model_setting.GetGlobalSettings().PassThroughRequestEnabled = original - }) - - input := `{ - "service_tier":"flex", - "safety_identifier":"user-123", - "stream_options":{"include_obfuscation":false} - }` - settings := dto.ChannelOtherSettings{} - out, err := RemoveDisabledFields([]byte(input), settings, false) - if err != nil { - t.Fatalf("RemoveDisabledFields returned error: %v", err) + _, 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") } - assertJSONEqual(t, input, string(out)) } -func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) { - input := `{ - "service_tier":"flex", - "inference_geo":"eu", - "safety_identifier":"user-123", - "store":true, - "stream_options":{"include_obfuscation":false} - }` - settings := dto.ChannelOtherSettings{} +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 := RemoveDisabledFields([]byte(input), settings, false) + out, err := ApplyParamOverride(input, override, nil) if err != nil { - t.Fatalf("RemoveDisabledFields returned error: %v", err) + t.Fatalf("ApplyParamOverride returned error: %v", err) } - assertJSONEqual(t, `{"store":true}`, string(out)) + 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 TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) { - input := `{ - "inference_geo":"eu", +func TestApplyParamOverridePruneObjectsWhereAndPath(t *testing.T) { + input := []byte(`{ + "a":{"items":[{"type":"redacted_thinking","id":1},{"type":"output_text","id":2}]}, + "b":{"items":[{"type":"redacted_thinking","id":3},{"type":"output_text","id":4}]} + }`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "a", + "mode": "prune_objects", + "value": map[string]interface{}{ + "where": map[string]interface{}{ + "type": "redacted_thinking", + }, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{ + "a":{"items":[{"type":"output_text","id":2}]}, + "b":{"items":[{"type":"redacted_thinking","id":3},{"type":"output_text","id":4}]} + }`, string(out)) +} + +func TestApplyParamOverrideNormalizeThinkingSignatureUnsupported(t *testing.T) { + input := []byte(`{"items":[{"type":"redacted_thinking"}]}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "normalize_thinking_signature", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideConditionFromRetryAndLastErrorContext(t *testing.T) { + info := &RelayInfo{ + RetryIndex: 1, + LastError: types.WithOpenAIError(types.OpenAIError{ + Message: "invalid thinking signature", + Type: "invalid_request_error", + Code: "bad_thought_signature", + }, 400), + } + ctx := BuildParamOverrideContext(info) + + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "logic": "AND", + "conditions": []interface{}{ + map[string]interface{}{ + "path": "is_retry", + "mode": "full", + "value": true, + }, + map[string]interface{}{ + "path": "last_error.code", + "mode": "contains", + "value": "thought_signature", + }, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.1}`, string(out)) +} + +func TestApplyParamOverrideConditionFromRequestHeaders(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "conditions": []interface{}{ + map[string]interface{}{ + "path": "request_headers.authorization", + "mode": "contains", + "value": "Bearer ", + }, + }, + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "authorization": "Bearer token-123", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.1}`, string(out)) +} + +func TestApplyParamOverrideSetHeaderAndUseInLaterCondition(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "X-Debug-Mode", + "value": "enabled", + }, + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "conditions": []interface{}{ + map[string]interface{}{ + "path": "header_override.x-debug-mode", + "mode": "full", + "value": "enabled", + }, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.1}`, string(out)) +} + +func TestApplyParamOverrideCopyHeaderFromRequestHeaders(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy_header", + "from": "Authorization", + "to": "X-Upstream-Auth", + }, + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "conditions": []interface{}{ + map[string]interface{}{ + "path": "header_override.x-upstream-auth", + "mode": "contains", + "value": "Bearer ", + }, + }, + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "authorization": "Bearer token-123", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.1}`, string(out)) +} + +func TestApplyParamOverridePassHeadersSkipsMissingHeaders(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "pass_headers", + "value": []interface{}{"X-Codex-Beta-Features", "Session_id"}, + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "session_id": "sess-123", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["session_id"] != "sess-123" { + t.Fatalf("expected session_id to be passed, got: %v", headers["session_id"]) + } + if _, exists := headers["x-codex-beta-features"]; exists { + t.Fatalf("expected missing header to be skipped") + } +} + +func TestApplyParamOverrideCopyHeaderSkipsMissingSource(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy_header", + "from": "X-Missing-Header", + "to": "X-Upstream-Auth", + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "authorization": "Bearer token-123", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + return + } + if _, exists := headers["x-upstream-auth"]; exists { + t.Fatalf("expected X-Upstream-Auth to be skipped when source header is missing") + } +} + +func TestApplyParamOverrideMoveHeaderSkipsMissingSource(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "move_header", + "from": "X-Missing-Header", + "to": "X-Upstream-Auth", + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "authorization": "Bearer token-123", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + return + } + if _, exists := headers["x-upstream-auth"]; exists { + t.Fatalf("expected X-Upstream-Auth to be skipped when source header is missing") + } +} + +func TestApplyParamOverrideSyncFieldsHeaderToJSON(t *testing.T) { + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "header:session_id", + "to": "json:prompt_cache_key", + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "session_id": "sess-123", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"sess-123"}`, string(out)) +} + +func TestApplyParamOverrideSyncFieldsJSONToHeader(t *testing.T) { + input := []byte(`{"model":"gpt-4","prompt_cache_key":"cache-abc"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "header:session_id", + "to": "json:prompt_cache_key", + }, + }, + } + ctx := map[string]interface{}{} + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"cache-abc"}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["session_id"] != "cache-abc" { + t.Fatalf("expected session_id to be synced from prompt_cache_key, got: %v", headers["session_id"]) + } +} + +func TestApplyParamOverrideSyncFieldsNoChangeWhenBothExist(t *testing.T) { + input := []byte(`{"model":"gpt-4","prompt_cache_key":"cache-body"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "header:session_id", + "to": "json:prompt_cache_key", + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "session_id": "cache-header", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","prompt_cache_key":"cache-body"}`, string(out)) + + headers, _ := ctx["header_override"].(map[string]interface{}) + if headers != nil { + if _, exists := headers["session_id"]; exists { + t.Fatalf("expected no override when both sides already have value") + } + } +} + +func TestApplyParamOverrideSyncFieldsInvalidTarget(t *testing.T) { + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "sync_fields", + "from": "foo:session_id", + "to": "json:prompt_cache_key", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideSetHeaderKeepOrigin(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "X-Feature-Flag", + "value": "new-value", + "keep_origin": true, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "x-feature-flag": "legacy-value", + }, + } + + _, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["x-feature-flag"] != "legacy-value" { + t.Fatalf("expected keep_origin to preserve old value, got: %v", headers["x-feature-flag"]) + } +} + +func TestApplyParamOverrideSetHeaderMapRewritesCommaSeparatedHeader(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "advanced-tool-use-2025-11-20": nil, + "computer-use-2025-01-24": "computer-use-2025-01-24", + }, + }, + }, + } + ctx := map[string]interface{}{ + "request_headers": map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20, computer-use-2025-01-24", + }, + } + + _, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["anthropic-beta"] != "computer-use-2025-01-24" { + t.Fatalf("expected anthropic-beta to keep only mapped value, got: %v", headers["anthropic-beta"]) + } +} + +func TestApplyParamOverrideSetHeaderMapDeleteWholeHeaderWhenAllTokensCleared(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "advanced-tool-use-2025-11-20": nil, + "computer-use-2025-01-24": nil, + }, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20,computer-use-2025-01-24", + }, + } + + _, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if _, exists := headers["anthropic-beta"]; exists { + t.Fatalf("expected anthropic-beta to be deleted when all mapped values are null") + } +} + +func TestApplyParamOverrideSetHeaderMapAppendsTokens(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "$append": []interface{}{"context-1m-2025-08-07", "computer-use-2025-01-24"}, + }, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "anthropic-beta": "computer-use-2025-01-24", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["anthropic-beta"] != "computer-use-2025-01-24,context-1m-2025-08-07" { + t.Fatalf("expected anthropic-beta to append new token without duplicates, got: %v", headers["anthropic-beta"]) + } +} + +func TestApplyParamOverrideSetHeaderMapAppendsTokensWhenHeaderMissing(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "$append": []interface{}{"context-1m-2025-08-07", "computer-use-2025-01-24"}, + }, + }, + }, + } + + ctx := map[string]interface{}{} + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["anthropic-beta"] != "context-1m-2025-08-07,computer-use-2025-01-24" { + t.Fatalf("expected anthropic-beta to be created from appended tokens, got: %v", headers["anthropic-beta"]) + } +} + +func TestApplyParamOverrideSetHeaderMapKeepOnlyDeclaredDropsUndeclaredTokens(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "computer-use-2025-01-24": "computer-use-2025-01-24", + "$append": []interface{}{"context-1m-2025-08-07"}, + "$keep_only_declared": true, + }, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20,computer-use-2025-01-24", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if headers["anthropic-beta"] != "computer-use-2025-01-24,context-1m-2025-08-07" { + t.Fatalf("expected anthropic-beta to keep only declared tokens, got: %v", headers["anthropic-beta"]) + } +} + +func TestApplyParamOverrideSetHeaderMapKeepOnlyDeclaredDeletesHeaderWhenNothingDeclaredMatches(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "computer-use-2025-01-24": "computer-use-2025-01-24", + "$keep_only_declared": true, + }, + }, + }, + } + ctx := map[string]interface{}{ + "header_override": map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20", + }, + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.7}`, string(out)) + + headers, ok := ctx["header_override"].(map[string]interface{}) + if !ok { + t.Fatalf("expected header_override context map") + } + if _, exists := headers["anthropic-beta"]; exists { + t.Fatalf("expected anthropic-beta to be deleted when no declared tokens remain, got: %v", headers["anthropic-beta"]) + } +} + +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 TestApplyParamOverrideWithRelayInfoMixedLegacyAndOperations(t *testing.T) { + info := &RelayInfo{ + RequestHeaders: map[string]string{ + "Originator": "Codex CLI", + }, + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "temperature": 0.2, + "operations": []interface{}{ + map[string]interface{}{ + "mode": "pass_headers", + "value": []interface{}{"Originator"}, + }, + }, + }, + HeadersOverride: map[string]interface{}{ + "X-Static": "legacy-static", + }, + }, + } + + out, err := ApplyParamOverrideWithRelayInfo([]byte(`{"model":"gpt-5","temperature":0.7}`), info) + if err != nil { + t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-5","temperature":0.2}`, string(out)) + + if !info.UseRuntimeHeadersOverride { + t.Fatalf("expected runtime header override to be enabled") + } + if info.RuntimeHeadersOverride["x-static"] != "legacy-static" { + t.Fatalf("expected x-static to be preserved, got: %v", info.RuntimeHeadersOverride["x-static"]) + } + if info.RuntimeHeadersOverride["originator"] != "Codex CLI" { + t.Fatalf("expected originator header to be passed, got: %v", info.RuntimeHeadersOverride["originator"]) + } +} + +func TestApplyParamOverrideWithRelayInfoMoveAndCopyHeaders(t *testing.T) { + info := &RelayInfo{ + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "move_header", + "from": "X-Legacy-Trace", + "to": "X-Trace", + }, + map[string]interface{}{ + "mode": "copy_header", + "from": "X-Trace", + "to": "X-Trace-Backup", + }, + }, + }, + HeadersOverride: map[string]interface{}{ + "X-Legacy-Trace": "trace-123", + }, + }, + } + + input := []byte(`{"temperature":0.7}`) + _, err := ApplyParamOverrideWithRelayInfo(input, info) + if err != nil { + t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err) + } + if _, exists := info.RuntimeHeadersOverride["x-legacy-trace"]; exists { + t.Fatalf("expected source header to be removed after move") + } + if info.RuntimeHeadersOverride["x-trace"] != "trace-123" { + t.Fatalf("expected x-trace to be set, got: %v", info.RuntimeHeadersOverride["x-trace"]) + } + if info.RuntimeHeadersOverride["x-trace-backup"] != "trace-123" { + t.Fatalf("expected x-trace-backup to be copied, got: %v", info.RuntimeHeadersOverride["x-trace-backup"]) + } +} + +func TestApplyParamOverrideWithRelayInfoSetHeaderMapRewritesAnthropicBeta(t *testing.T) { + info := &RelayInfo{ + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "set_header", + "path": "anthropic-beta", + "value": map[string]interface{}{ + "advanced-tool-use-2025-11-20": nil, + "computer-use-2025-01-24": "computer-use-2025-01-24", + }, + }, + }, + }, + HeadersOverride: map[string]interface{}{ + "anthropic-beta": "advanced-tool-use-2025-11-20, computer-use-2025-01-24", + }, + }, + } + + _, err := ApplyParamOverrideWithRelayInfo([]byte(`{"temperature":0.7}`), info) + if err != nil { + t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err) + } + + if !info.UseRuntimeHeadersOverride { + t.Fatalf("expected runtime header override to be enabled") + } + if info.RuntimeHeadersOverride["anthropic-beta"] != "computer-use-2025-01-24" { + t.Fatalf("expected anthropic-beta to be rewritten, got: %v", info.RuntimeHeadersOverride["anthropic-beta"]) + } +} + +func TestGetEffectiveHeaderOverrideUsesRuntimeOverrideAsFinalResult(t *testing.T) { + info := &RelayInfo{ + UseRuntimeHeadersOverride: true, + RuntimeHeadersOverride: map[string]interface{}{ + "x-runtime": "runtime-only", + }, + ChannelMeta: &ChannelMeta{ + HeadersOverride: map[string]interface{}{ + "X-Static": "static-value", + "X-Deleted": "should-not-exist", + }, + }, + } + + effective := GetEffectiveHeaderOverride(info) + if effective["x-runtime"] != "runtime-only" { + t.Fatalf("expected x-runtime from runtime override, got: %v", effective["x-runtime"]) + } + if _, exists := effective["x-static"]; exists { + t.Fatalf("expected runtime override to be final and not merge channel headers") + } +} + +func TestRemoveDisabledFieldsSkipWhenChannelPassThroughEnabled(t *testing.T) { + input := `{ + "service_tier":"flex", + "safety_identifier":"user-123", + "store":true, + "stream_options":{"include_obfuscation":false} + }` + settings := dto.ChannelOtherSettings{} + + out, err := RemoveDisabledFields([]byte(input), settings, true) + if err != nil { + t.Fatalf("RemoveDisabledFields returned error: %v", err) + } + assertJSONEqual(t, input, string(out)) +} + +func TestRemoveDisabledFieldsSkipWhenGlobalPassThroughEnabled(t *testing.T) { + original := model_setting.GetGlobalSettings().PassThroughRequestEnabled + model_setting.GetGlobalSettings().PassThroughRequestEnabled = true + t.Cleanup(func() { + model_setting.GetGlobalSettings().PassThroughRequestEnabled = original + }) + + input := `{ + "service_tier":"flex", + "safety_identifier":"user-123", + "stream_options":{"include_obfuscation":false} + }` + settings := dto.ChannelOtherSettings{} + + out, err := RemoveDisabledFields([]byte(input), settings, false) + if err != nil { + t.Fatalf("RemoveDisabledFields returned error: %v", err) + } + assertJSONEqual(t, input, string(out)) +} + +func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) { + input := `{ + "service_tier":"flex", + "inference_geo":"eu", + "safety_identifier":"user-123", + "store":true, + "stream_options":{"include_obfuscation":false} + }` + settings := dto.ChannelOtherSettings{} + + out, err := RemoveDisabledFields([]byte(input), settings, false) + if err != nil { + t.Fatalf("RemoveDisabledFields returned error: %v", err) + } + assertJSONEqual(t, `{"store":true}`, string(out)) +} + +func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) { + input := `{ + "inference_geo":"eu", "store":true }` settings := dto.ChannelOtherSettings{ @@ -845,6 +2067,105 @@ func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) { assertJSONEqual(t, `{"inference_geo":"eu","store":true}`, string(out)) } +func TestApplyParamOverrideWithRelayInfoRecordsOperationAuditInDebugMode(t *testing.T) { + originalDebugEnabled := common2.DebugEnabled + common2.DebugEnabled = true + t.Cleanup(func() { + common2.DebugEnabled = originalDebugEnabled + }) + + info := &RelayInfo{ + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy", + "from": "metadata.target_model", + "to": "model", + }, + map[string]interface{}{ + "mode": "set", + "path": "service_tier", + "value": "flex", + }, + map[string]interface{}{ + "mode": "set", + "path": "temperature", + "value": 0.1, + }, + }, + }, + }, + } + + out, err := ApplyParamOverrideWithRelayInfo([]byte(`{ + "model":"gpt-4.1", + "temperature":0.7, + "metadata":{"target_model":"gpt-4.1-mini"} + }`), info) + if err != nil { + t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err) + } + assertJSONEqual(t, `{ + "model":"gpt-4.1-mini", + "temperature":0.1, + "service_tier":"flex", + "metadata":{"target_model":"gpt-4.1-mini"} + }`, string(out)) + + expected := []string{ + "copy metadata.target_model -> model", + "set service_tier = flex", + "set temperature = 0.1", + } + if !reflect.DeepEqual(info.ParamOverrideAudit, expected) { + t.Fatalf("unexpected param override audit, got %#v", info.ParamOverrideAudit) + } +} + +func TestApplyParamOverrideWithRelayInfoRecordsOnlyKeyOperationsWhenDebugDisabled(t *testing.T) { + originalDebugEnabled := common2.DebugEnabled + common2.DebugEnabled = false + t.Cleanup(func() { + common2.DebugEnabled = originalDebugEnabled + }) + + info := &RelayInfo{ + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy", + "from": "metadata.target_model", + "to": "model", + }, + map[string]interface{}{ + "mode": "set", + "path": "temperature", + "value": 0.1, + }, + }, + }, + }, + } + + _, err := ApplyParamOverrideWithRelayInfo([]byte(`{ + "model":"gpt-4.1", + "temperature":0.7, + "metadata":{"target_model":"gpt-4.1-mini"} + }`), info) + if err != nil { + t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err) + } + + expected := []string{ + "copy metadata.target_model -> model", + } + if !reflect.DeepEqual(info.ParamOverrideAudit, expected) { + t.Fatalf("unexpected param override audit, got %#v", info.ParamOverrideAudit) + } +} + 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 6d286d6..66f95c9 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -144,6 +144,12 @@ type RelayInfo struct { SubscriptionAmountUsedAfterPreConsume int64 IsClaudeBetaQuery bool // /v1/messages?beta=true IsChannelTest bool // channel test request + RetryIndex int + LastError *types.NewAPIError + RequestHeaders map[string]string + RuntimeHeadersOverride map[string]interface{} + UseRuntimeHeadersOverride bool + ParamOverrideAudit []string PriceData types.PriceData @@ -473,6 +479,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { //promptTokens: common.GetContextKeyInt(c, constant.ContextKeyPromptTokens), estimatePromptTokens: common.GetContextKeyInt(c, constant.ContextKeyEstimatedTokens), }, + RequestHeaders: cloneRequestHeaders(c), } if info.RelayMode == relayconstant.RelayModeUnknown { @@ -493,6 +500,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 a133bab..eb52d6a 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 types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index 1a41756..3acfb47 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -52,7 +52,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 types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index a1b8e59..383c566 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 types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } diff --git a/relay/image_handler.go b/relay/image_handler.go index e832942..9d21809 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 types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go index 8e7c61e..63bc907 100644 --- a/relay/mjproxy_handler.go +++ b/relay/mjproxy_handler.go @@ -49,6 +49,13 @@ func RelayMidjourneyImage(c *gin.Context) { if httpClient == nil { httpClient = service.GetHttpClient() } + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(midjourneyTask.ImageUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + c.JSON(http.StatusForbidden, gin.H{ + "error": fmt.Sprintf("request blocked: %v", err), + }) + return + } resp, err := httpClient.Get(midjourneyTask.ImageUrl) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 8fe2930..1f22f19 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 types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index b3169e7..0f64d5a 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 types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } diff --git a/router/api-router.go b/router/api-router.go index 5b80d5c..9e87f8d 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -246,7 +246,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/batch", controller.DeleteChannelBatch) channelRoute.POST("/fix", controller.FixChannelsAbilities) channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) - channelRoute.POST("/fetch_models", controller.FetchModels) + channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels) channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth) channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) @@ -396,6 +396,7 @@ func SetApiRouter(router *gin.Engine) { syncRoute.POST("/user/create", controller.ReceiveSyncedUserCreate) syncRoute.POST("/quota/update", controller.ReceiveQuotaUpdate) syncRoute.POST("/quota/query", controller.QueryUserQuota) + syncRoute.POST("/quota/batch-query", controller.BatchQueryUserQuota) syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota) syncRoute.GET("/config", controller.GetSyncConfig) } diff --git a/service/region_sync/sync_client.go b/service/region_sync/sync_client.go index 8af24ce..cbd8c42 100644 --- a/service/region_sync/sync_client.go +++ b/service/region_sync/sync_client.go @@ -142,3 +142,15 @@ func (c *SyncClient) FetchConfig() (*SyncConfigResponse, error) { } return &resp, nil } + +func (c *SyncClient) BatchQueryQuota(userIds []int) (*BatchQueryQuotaResponse, error) { + data, err := c.doRequest("POST", "/api/internal/sync/quota/batch-query", &BatchQueryQuotaRequest{UserIds: userIds}) + if err != nil { + return nil, err + } + var resp BatchQueryQuotaResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + return &resp, nil +} diff --git a/service/region_sync/sync_manager.go b/service/region_sync/sync_manager.go index b2e6ae9..341c101 100644 --- a/service/region_sync/sync_manager.go +++ b/service/region_sync/sync_manager.go @@ -143,7 +143,7 @@ func (m *SyncManager) QueryMasterQuota(remoteUserId int) (int, error) { return resp.Quota, nil } -// RunQuotaSync 从 Master 拉取所有同步用户的最新余额,更新本地 synced_quota +// RunQuotaSync 从 Master 批量拉取所有同步用户的最新余额,更新本地 synced_quota func (m *SyncManager) RunQuotaSync() int { settings := system_setting.GetRegionSyncSettings() if !settings.Enabled || settings.IsMaster { @@ -157,24 +157,42 @@ func (m *SyncManager) RunQuotaSync() int { logger.LogDebug(nil, "[RegionSync] RunQuotaSync: syncing %d users", len(users)) + // 建立 remoteUserId -> local User 映射 + userMap := make(map[int]*model.User, len(users)) + remoteIds := make([]int, len(users)) + for i := range users { + userMap[users[i].RemoteUserId] = &users[i] + remoteIds[i] = users[i].RemoteUserId + } + syncedCount := 0 - for _, user := range users { - resp, err := m.client.QueryQuota(user.RemoteUserId) - if err != nil { - common.SysError(fmt.Sprintf("[SyncManager] QueryQuota failed for user %d: %v", user.Id, err)) - continue + batchSize := 500 + for i := 0; i < len(remoteIds); i += batchSize { + end := i + batchSize + if end > len(remoteIds) { + end = len(remoteIds) } - if !resp.Success { + batch := remoteIds[i:end] + + resp, err := m.client.BatchQueryQuota(batch) + if err != nil { + common.SysError(fmt.Sprintf("[SyncManager] BatchQueryQuota failed (batch %d-%d): %v", i, end, err)) continue } - oldQuota := user.SyncedQuota - if err := model.UpdateSyncedQuota(user.Id, resp.Quota); err != nil { - common.SysError(fmt.Sprintf("[SyncManager] UpdateSyncedQuota failed for user %d: %v", user.Id, err)) - continue + + for _, entry := range resp.Quotas { + if user, ok := userMap[entry.UserId]; ok { + oldQuota := user.SyncedQuota + if err := model.UpdateSyncedQuota(user.Id, entry.Quota); err != nil { + common.SysError(fmt.Sprintf("[SyncManager] UpdateSyncedQuota failed for user %d: %v", user.Id, err)) + continue + } + logger.LogDebug(nil, "[RegionSync] RunQuotaSync: userId=%d, syncedQuota %d -> %d", user.Id, oldQuota, entry.Quota) + syncedCount++ + } } - logger.LogDebug(nil, "[RegionSync] RunQuotaSync: userId=%d, syncedQuota %d -> %d", user.Id, oldQuota, resp.Quota) - syncedCount++ } + logger.LogDebug(nil, "[RegionSync] RunQuotaSync: completed, synced=%d/%d", syncedCount, len(users)) return syncedCount } diff --git a/service/region_sync/sync_types.go b/service/region_sync/sync_types.go index d60a13b..31e18ed 100644 --- a/service/region_sync/sync_types.go +++ b/service/region_sync/sync_types.go @@ -81,3 +81,21 @@ type SyncConfigResponse struct { SyncIntervalSeconds int `json:"sync_interval_seconds"` Error string `json:"error,omitempty"` } + +// QuotaEntry 单个用户的余额信息 +type QuotaEntry struct { + UserId int `json:"user_id"` + Quota int `json:"quota"` +} + +// BatchQueryQuotaRequest 批量查询余额请求 +type BatchQueryQuotaRequest struct { + UserIds []int `json:"user_ids"` +} + +// BatchQueryQuotaResponse 批量查询余额响应 +type BatchQueryQuotaResponse struct { + Success bool `json:"success"` + Quotas []QuotaEntry `json:"quotas"` + Error string `json:"error,omitempty"` +} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 62a0010..6014f3f 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -357,6 +357,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error { func GetModelPrice(name string, printErr bool) (float64, bool) { name = FormatMatchingModelName(name) + // 优先匹配精确模型名称 + if price, ok := modelPriceMap.Get(name); ok { + return price, true + } + if strings.HasSuffix(name, CompactModelSuffix) { price, ok := modelPriceMap.Get(CompactWildcardModelKey) if !ok { diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go index 0786961..c71be03 100644 --- a/setting/system_setting/fetch_setting.go +++ b/setting/system_setting/fetch_setting.go @@ -21,7 +21,7 @@ var defaultFetchSetting = FetchSetting{ DomainList: []string{}, IpList: []string{}, AllowedPorts: []string{"80", "443", "8080", "8443"}, - ApplyIPFilterForDomain: false, + ApplyIPFilterForDomain: true, } func init() { diff --git a/update-image.sh b/update-image.sh new file mode 100644 index 0000000..cca68f4 --- /dev/null +++ b/update-image.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# 用法: ./update-image.sh [目录1] [目录2] ... +# 示例: ./update-image.sh /opt/ov /opt/cn 202604071354-feat-region-sync-glm +# +# 脚本会修改指定目录下 docker-compose.yml 中 new-api 服务的 image tag。 +# 如果不传目录参数,默认修改当前目录。 + +set -e + +if [ $# -lt 1 ]; then + echo "错误: 请提供新的 tag" + echo "用法: $0 [目录1] [目录2] ... " + echo "示例: $0 /opt/ov /opt/cn 202604071354-feat-region-sync-glm" + exit 1 +fi + +TAG="${@: -1}" + +if [ $# -gt 1 ]; then + DIRS=("${@:1:$#-1}") +else + DIRS=(".") +fi + +update_file() { + local FILE="$1" + local TAG="$2" + + if [ ! -f "$FILE" ]; then + echo "跳过: $FILE 不存在" + return + fi + + OLD_IMAGE=$(grep -E '^\s+image:.*new-api' "$FILE" | head -1 | sed 's/.*image: *//') + OLD_TAG="${OLD_IMAGE##*:}" + REPO="${OLD_IMAGE%:*}" + + if [ -z "$OLD_TAG" ] || [ -z "$REPO" ]; then + echo "错误: 无法从 $FILE 中解析 new-api 的 image 配置" + return 1 + fi + + NEW_IMAGE="${REPO}:${TAG}" + + if [[ "$OSTYPE" == "darwin"* ]]; then + perl -i -pe "s|(\s+image:) *${OLD_IMAGE}|\$1 ${NEW_IMAGE}|" "$FILE" + else + sed -i "s|${OLD_IMAGE}|${NEW_IMAGE}|" "$FILE" + fi + + echo "已更新 $FILE:" + echo " 旧: ${OLD_IMAGE}" + echo " 新: ${NEW_IMAGE}" +} + +for DIR in "${DIRS[@]}"; do + FILE="${DIR%/}/docker-compose.yml" + update_file "$FILE" "$TAG" +done diff --git a/web/src/components/common/DocumentRenderer/index.jsx b/web/src/components/common/DocumentRenderer/index.jsx index 68e868c..8c15c07 100644 --- a/web/src/components/common/DocumentRenderer/index.jsx +++ b/web/src/components/common/DocumentRenderer/index.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { API, showError } from '../../../helpers'; import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; const { Title } = Typography; @@ -28,7 +28,7 @@ import { import { useTranslation } from 'react-i18next'; import MarkdownRenderer from '../markdown/MarkdownRenderer'; -// 检查是否为 URL +// Check whether content is a URL. const isUrl = (content) => { try { new URL(content.trim()); @@ -38,27 +38,23 @@ const isUrl = (content) => { } }; -// 检查是否为 HTML 内容 +// Check whether content contains HTML. const isHtmlContent = (content) => { if (!content || typeof content !== 'string') return false; - // 检查是否包含HTML标签 const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; return htmlTagRegex.test(content); }; -// 安全地渲染HTML内容 +// Parse HTML content and extract inline styles. const sanitizeHtml = (html) => { - // 创建一个临时元素来解析HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; - // 提取样式 const styles = Array.from(tempDiv.querySelectorAll('style')) .map((style) => style.innerHTML) .join('\n'); - // 提取body内容,如果没有body标签则使用全部内容 const bodyContent = tempDiv.querySelector('body'); const content = bodyContent ? bodyContent.innerHTML : html; @@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { const { t } = useTranslation(); const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); - const [htmlStyles, setHtmlStyles] = useState(''); - const [processedHtmlContent, setProcessedHtmlContent] = useState(''); const loadContent = async () => { - // 先从缓存中获取 const cachedContent = localStorage.getItem(cacheKey) || ''; if (cachedContent) { setContent(cachedContent); - processContent(cachedContent); setLoading(false); } @@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { const { success, message, data } = res.data; if (success && data) { setContent(data); - processContent(data); localStorage.setItem(cacheKey, data); } else { if (!cachedContent) { @@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { } }; - const processContent = (rawContent) => { - if (isHtmlContent(rawContent)) { - const { content: htmlContent, styles } = sanitizeHtml(rawContent); - setProcessedHtmlContent(htmlContent); - setHtmlStyles(styles); - } else { - setProcessedHtmlContent(''); - setHtmlStyles(''); + const htmlPayload = useMemo(() => { + if (!isHtmlContent(content)) { + return { content: '', styles: '' }; } - }; + return sanitizeHtml(content); + }, [content]); useEffect(() => { loadContent(); @@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { // 处理HTML样式注入 useEffect(() => { const styleId = `document-renderer-styles-${cacheKey}`; + const { styles } = htmlPayload; - if (htmlStyles) { + if (styles) { let styleEl = document.getElementById(styleId); if (!styleEl) { styleEl = document.createElement('style'); @@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { styleEl.type = 'text/css'; document.head.appendChild(styleEl); } - styleEl.innerHTML = htmlStyles; + styleEl.innerHTML = styles; } else { const el = document.getElementById(styleId); if (el) el.remove(); @@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { const el = document.getElementById(styleId); if (el) el.remove(); }; - }, [htmlStyles, cacheKey]); + }, [cacheKey, htmlPayload]); // 显示加载状态 if (loading) { @@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { // 如果是 HTML 内容,直接渲染 if (isHtmlContent(content)) { - const { content: htmlContent, styles } = sanitizeHtml(content); - - // 设置样式(如果有的话) - useEffect(() => { - if (styles && styles !== htmlStyles) { - setHtmlStyles(styles); - } - }, [content, styles, htmlStyles]); - return (
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
diff --git a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx index 18e2cfb..93873b8 100644 --- a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx +++ b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx @@ -595,7 +595,7 @@ export default function SettingsChannelAffinity(props) { include_rule_name: !!values.include_rule_name, ...(values.skip_retry_on_failure ? { skip_retry_on_failure: true } - : {}), + : { skip_retry_on_failure: false }), ...(userAgentInclude.length > 0 ? { user_agent_include: userAgentInclude } : {}),