Delete leftover channel pricing controllers and routes after removing channel pricing, and align relay price helpers with global pricing plus user channel ratio only. Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -1,359 +0,0 @@ | |||
| package controller | |||
| import ( | |||
| "fmt" | |||
| "strconv" | |||
| "strings" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| // GetAllChannelPricing 获取所有渠道定价(分页) | |||
| func GetAllChannelPricing(c *gin.Context) { | |||
| page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) | |||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) | |||
| offset := (page - 1) * pageSize | |||
| list, total, err := model.GetAllChannelPricing(offset, pageSize) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.ApiSuccess(c, gin.H{ | |||
| "page": page, | |||
| "page_size": pageSize, | |||
| "total": total, | |||
| "items": list, | |||
| }) | |||
| } | |||
| // GetChannelPricingByModel 获取指定模型的所有渠道定价 | |||
| func GetChannelPricingByModel(c *gin.Context) { | |||
| modelName := c.Param("name") | |||
| if modelName == "" { | |||
| common.ApiErrorMsg(c, "model name is required") | |||
| return | |||
| } | |||
| list, err := model.GetChannelPricingByModel(modelName) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.ApiSuccess(c, list) | |||
| } | |||
| // CreateChannelPricingRequest 创建渠道定价请求 | |||
| type CreateChannelPricingRequest struct { | |||
| Id int `json:"id"` | |||
| ModelName string `json:"model_name" binding:"required"` | |||
| ChannelId int `json:"channel_id" binding:"required"` | |||
| QuotaType int `json:"quota_type"` | |||
| ModelRatio float64 `json:"model_ratio"` | |||
| CompletionRatio float64 `json:"completion_ratio"` | |||
| ModelPrice float64 `json:"model_price"` | |||
| TagIds string `json:"tag_ids"` | |||
| CacheRatio float64 `json:"cache_ratio"` | |||
| CacheCreationRatio float64 `json:"cache_creation_ratio"` | |||
| ImageRatio float64 `json:"image_ratio"` | |||
| AudioRatio float64 `json:"audio_ratio"` | |||
| AudioCompletionRatio float64 `json:"audio_completion_ratio"` | |||
| } | |||
| // applyRequest 将请求字段应用到 ChannelPricing | |||
| func applyRequestFields(cp *model.ChannelPricing, req *CreateChannelPricingRequest) { | |||
| cp.ApplyFields(req.QuotaType, req.ModelRatio, req.CompletionRatio, req.ModelPrice, req.TagIds, | |||
| req.CacheRatio, req.CacheCreationRatio, req.ImageRatio, req.AudioRatio, req.AudioCompletionRatio) | |||
| } | |||
| // CreateChannelPricing 创建或更新渠道定价 | |||
| func CreateChannelPricing(c *gin.Context) { | |||
| var req CreateChannelPricingRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| if req.CacheRatio < 0 || req.CacheCreationRatio < 0 || req.ImageRatio < 0 || req.AudioRatio < 0 || req.AudioCompletionRatio < 0 { | |||
| common.ApiErrorMsg(c, "ratio values must be >= 0") | |||
| return | |||
| } | |||
| // 检查是否已存在 | |||
| existing, _ := model.GetChannelPricing(req.ModelName, req.ChannelId) | |||
| if existing != nil { | |||
| applyRequestFields(existing, &req) | |||
| if err := existing.Update(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.SysLog(fmt.Sprintf("[ChannelPricing] updated: id=%d model=%s channel=%d", existing.Id, existing.ModelName, existing.ChannelId)) | |||
| common.ApiSuccess(c, existing) | |||
| return | |||
| } | |||
| // 创建 | |||
| cp := &model.ChannelPricing{ | |||
| ModelName: req.ModelName, | |||
| ChannelId: req.ChannelId, | |||
| } | |||
| applyRequestFields(cp, &req) | |||
| if err := cp.Insert(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.SysLog(fmt.Sprintf("[ChannelPricing] created: model=%s channel=%d quotaType=%d modelRatio=%.4f completionRatio=%.4f modelPrice=%.4f cacheRatio=%.4f cacheCreationRatio=%.4f imageRatio=%.4f audioRatio=%.4f audioCompletionRatio=%.4f", | |||
| req.ModelName, req.ChannelId, req.QuotaType, req.ModelRatio, req.CompletionRatio, req.ModelPrice, | |||
| req.CacheRatio, req.CacheCreationRatio, req.ImageRatio, req.AudioRatio, req.AudioCompletionRatio)) | |||
| common.ApiSuccess(c, cp) | |||
| } | |||
| // BatchCreateChannelPricingRequest 批量创建请求 | |||
| type BatchCreateChannelPricingRequest struct { | |||
| Items []*CreateChannelPricingRequest `json:"items" binding:"required"` | |||
| } | |||
| // BatchCreateChannelPricing 批量创建或更新渠道定价 | |||
| func BatchCreateChannelPricing(c *gin.Context) { | |||
| var req BatchCreateChannelPricingRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| pricings := make([]*model.ChannelPricing, 0, len(req.Items)) | |||
| for _, item := range req.Items { | |||
| cp := &model.ChannelPricing{ | |||
| ModelName: item.ModelName, | |||
| ChannelId: item.ChannelId, | |||
| } | |||
| applyRequestFields(cp, item) | |||
| pricings = append(pricings, cp) | |||
| } | |||
| // 使用事务逐个处理(GORM 的批量 upsert 在不同数据库表现不一致) | |||
| for _, cp := range pricings { | |||
| existing, _ := model.GetChannelPricing(cp.ModelName, cp.ChannelId) | |||
| if existing != nil { | |||
| existing.ApplyFields(cp.QuotaType, cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.TagIds, | |||
| cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio) | |||
| if err := existing.Update(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| } else { | |||
| if err := cp.Insert(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| } | |||
| } | |||
| common.ApiSuccess(c, gin.H{"affected": len(pricings)}) | |||
| } | |||
| // DeleteChannelPricing 删除渠道定价 | |||
| func DeleteChannelPricing(c *gin.Context) { | |||
| idStr := c.Param("id") | |||
| id, err := strconv.Atoi(idStr) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| cp := &model.ChannelPricing{Id: id} | |||
| if err := cp.Delete(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.SysLog(fmt.Sprintf("[ChannelPricing] deleted: id=%d", id)) | |||
| common.ApiSuccess(c, nil) | |||
| } | |||
| // CopyGlobalPricingRequest 复制全局定价请求 | |||
| type CopyGlobalPricingRequest struct { | |||
| Overwrite bool `json:"overwrite"` // 是否覆盖已存在的渠道定价 | |||
| } | |||
| // CopyGlobalPricing 复制全局定价到指定渠道 | |||
| // 从 ratio_setting 读取全局定价信息,复制到 channel_pricing 表 | |||
| func CopyGlobalPricing(c *gin.Context) { | |||
| channelIdStr := c.Param("channel_id") | |||
| channelId, err := strconv.Atoi(channelIdStr) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| var req CopyGlobalPricingRequest | |||
| c.ShouldBindJSON(&req) | |||
| // 获取该渠道支持的所有模型(使用 GetAbilitiesByChannelId) | |||
| abilities, err := model.GetAbilitiesByChannelId(channelId) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| imported := 0 | |||
| for _, ability := range abilities { | |||
| // 检查是否已存在 | |||
| existing, _ := model.GetChannelPricing(ability.Model, channelId) | |||
| if existing != nil && !req.Overwrite { | |||
| continue | |||
| } | |||
| // 获取全局扩展比率 | |||
| globalCacheRatio, hasCacheRatio := ratio_setting.GetCacheRatio(ability.Model) | |||
| if !hasCacheRatio { | |||
| globalCacheRatio = 0 | |||
| } | |||
| globalCacheCreationRatio, hasCacheCreationRatio := ratio_setting.GetCreateCacheRatio(ability.Model) | |||
| if !hasCacheCreationRatio { | |||
| globalCacheCreationRatio = 0 | |||
| } | |||
| globalImageRatio, hasImageRatio := ratio_setting.GetImageRatio(ability.Model) | |||
| if !hasImageRatio { | |||
| globalImageRatio = 0 | |||
| } | |||
| globalAudioRatio, hasAudioRatio := ratio_setting.GetAudioRatioV2(ability.Model) | |||
| if !hasAudioRatio { | |||
| globalAudioRatio = 0 | |||
| } | |||
| globalAudioCompletionRatio, hasAudioCompRatio := ratio_setting.GetAudioCompletionRatioV2(ability.Model) | |||
| if !hasAudioCompRatio { | |||
| globalAudioCompletionRatio = 0 | |||
| } | |||
| // 确定定价类型 | |||
| var quotaType int | |||
| var ratio, completionRatio, price float64 | |||
| // 优先检查是否有按次计费的价格 | |||
| modelPrice, hasPrice := ratio_setting.GetModelPrice(ability.Model, false) | |||
| if hasPrice { | |||
| quotaType = model.QuotaTypeByCall | |||
| price = modelPrice | |||
| } else { | |||
| // 使用按量计费 | |||
| quotaType = model.QuotaTypeByTokens | |||
| modelRatio, hasRatio, _ := ratio_setting.GetModelRatio(ability.Model) | |||
| if hasRatio { | |||
| ratio = modelRatio | |||
| } | |||
| completionRatio = ratio_setting.GetCompletionRatio(ability.Model) | |||
| } | |||
| if existing != nil { | |||
| existing.ApplyFields(quotaType, ratio, completionRatio, price, "", | |||
| globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio) | |||
| if err := existing.Update(); err == nil { | |||
| imported++ | |||
| } | |||
| } else { | |||
| cp := &model.ChannelPricing{ | |||
| ModelName: ability.Model, | |||
| ChannelId: channelId, | |||
| } | |||
| cp.ApplyFields(quotaType, ratio, completionRatio, price, "", | |||
| globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio) | |||
| if err := cp.Insert(); err == nil { | |||
| imported++ | |||
| } | |||
| } | |||
| } | |||
| common.SysLog(fmt.Sprintf("[ChannelPricing] copyGlobalPricing: channel=%d imported=%d/%d", channelId, imported, len(abilities))) | |||
| common.ApiSuccess(c, gin.H{ | |||
| "total": len(abilities), | |||
| "imported": imported, | |||
| }) | |||
| } | |||
| // ChannelPricingWithTags 带标签详情的渠道定价响应 | |||
| type ChannelPricingWithTags struct { | |||
| *model.ChannelPricing | |||
| Tags []*model.PricingTag `json:"tags"` | |||
| } | |||
| // GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息,所有用户可访问) | |||
| func GetChannelPricingByModelWithChannelInfo(c *gin.Context) { | |||
| // 使用通配符路由时,参数包含前导斜杠,需要去除 | |||
| modelName := c.Param("name") | |||
| if modelName == "" { | |||
| common.ApiErrorMsg(c, "model name is required") | |||
| return | |||
| } | |||
| // 去除前导斜杠(路由是 /channel-pricing/model/*name,name 会是 "/deepseek-ai/xxx") | |||
| modelName = strings.TrimPrefix(modelName, "/") | |||
| list, err := model.GetChannelPricingByModelWithChannelInfo(modelName) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| loggedIn, userId, userGroup, groupRatio := resolveCurrentUser(c) | |||
| if loggedIn { | |||
| for _, item := range list { | |||
| userChannelRatio := model.GetUserChannelRatio(userId, modelName, item.ChannelId) | |||
| item.UserRatio = groupRatio * userChannelRatio | |||
| } | |||
| } | |||
| c.JSON(200, gin.H{ | |||
| "success": true, | |||
| "message": "", | |||
| "data": list, | |||
| "logged_in": loggedIn, | |||
| "group": userGroup, | |||
| "group_ratio": groupRatio, | |||
| }) | |||
| } | |||
| // GetChannelPricingWithTags 获取渠道定价(带标签详情) | |||
| func GetChannelPricingWithTags(c *gin.Context) { | |||
| page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) | |||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) | |||
| offset := (page - 1) * pageSize | |||
| list, total, err := model.GetAllChannelPricing(offset, pageSize) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| // 获取所有标签 | |||
| tags, _ := model.GetAllPricingTags() | |||
| tagMap := make(map[int]*model.PricingTag) | |||
| for _, tag := range tags { | |||
| tagMap[tag.Id] = tag | |||
| } | |||
| // 为每个定价填充标签详情 | |||
| result := make([]*ChannelPricingWithTags, 0, len(list)) | |||
| for _, cp := range list { | |||
| item := &ChannelPricingWithTags{ | |||
| ChannelPricing: cp, | |||
| Tags: model.ParseTagIds(cp.TagIds, tagMap), | |||
| } | |||
| result = append(result, item) | |||
| } | |||
| common.ApiSuccess(c, gin.H{ | |||
| "page": page, | |||
| "page_size": pageSize, | |||
| "total": total, | |||
| "items": result, | |||
| }) | |||
| } | |||
| @@ -1,131 +0,0 @@ | |||
| package controller | |||
| import ( | |||
| "strconv" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| // GetAllPricingTags 获取所有定价标签 | |||
| func GetAllPricingTags(c *gin.Context) { | |||
| list, err := model.GetAllPricingTags() | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.ApiSuccess(c, list) | |||
| } | |||
| // CreatePricingTagRequest 创建定价标签请求 | |||
| type CreatePricingTagRequest struct { | |||
| Name string `json:"name" binding:"required"` | |||
| Color string `json:"color"` | |||
| Description string `json:"description"` | |||
| SortOrder int `json:"sort_order"` | |||
| } | |||
| // CreatePricingTag 创建定价标签 | |||
| func CreatePricingTag(c *gin.Context) { | |||
| var req CreatePricingTagRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| // 检查名称是否重复 | |||
| existing, _ := model.GetPricingTagByName(req.Name) | |||
| if existing != nil { | |||
| common.ApiErrorMsg(c, "tag name already exists") | |||
| return | |||
| } | |||
| pt := &model.PricingTag{ | |||
| Name: req.Name, | |||
| Color: req.Color, | |||
| Description: req.Description, | |||
| SortOrder: req.SortOrder, | |||
| } | |||
| if pt.Color == "" { | |||
| pt.Color = "#1890ff" | |||
| } | |||
| if err := pt.Insert(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.ApiSuccess(c, pt) | |||
| } | |||
| // UpdatePricingTagRequest 更新定价标签请求 | |||
| type UpdatePricingTagRequest struct { | |||
| Name string `json:"name"` | |||
| Color string `json:"color"` | |||
| Description string `json:"description"` | |||
| SortOrder int `json:"sort_order"` | |||
| } | |||
| // UpdatePricingTag 更新定价标签 | |||
| func UpdatePricingTag(c *gin.Context) { | |||
| idStr := c.Param("id") | |||
| id, err := strconv.Atoi(idStr) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| var req UpdatePricingTagRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| pt, err := model.GetPricingTagById(id) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| // 检查名称是否与其他标签重复 | |||
| if req.Name != "" && req.Name != pt.Name { | |||
| existing, _ := model.GetPricingTagByName(req.Name) | |||
| if existing != nil && existing.Id != id { | |||
| common.ApiErrorMsg(c, "tag name already exists") | |||
| return | |||
| } | |||
| pt.Name = req.Name | |||
| } | |||
| if req.Color != "" { | |||
| pt.Color = req.Color | |||
| } | |||
| pt.Description = req.Description | |||
| pt.SortOrder = req.SortOrder | |||
| if err := pt.Update(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.ApiSuccess(c, pt) | |||
| } | |||
| // DeletePricingTag 删除定价标签 | |||
| func DeletePricingTag(c *gin.Context) { | |||
| idStr := c.Param("id") | |||
| id, err := strconv.Atoi(idStr) | |||
| if err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| pt := &model.PricingTag{Id: id} | |||
| if err := pt.Delete(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| common.ApiSuccess(c, nil) | |||
| } | |||
| @@ -18,26 +18,22 @@ import ( | |||
| // HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present | |||
| func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.GroupRatioInfo { | |||
| groupRatioInfo := types.GroupRatioInfo{ | |||
| GroupRatio: 1.0, // default ratio | |||
| GroupRatio: 1.0, | |||
| GroupSpecialRatio: -1, | |||
| } | |||
| // check auto group | |||
| autoGroup, exists := ctx.Get("auto_group") | |||
| if exists { | |||
| logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup)) | |||
| relayInfo.UsingGroup = autoGroup.(string) | |||
| } | |||
| // check user group special ratio | |||
| userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) | |||
| if ok { | |||
| // user group special ratio | |||
| groupRatioInfo.GroupSpecialRatio = userGroupRatio | |||
| groupRatioInfo.GroupRatio = userGroupRatio | |||
| groupRatioInfo.HasSpecialRatio = true | |||
| } else { | |||
| // normal group ratio | |||
| groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup) | |||
| } | |||
| @@ -49,39 +45,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||
| var usePrice bool | |||
| var modelRatio float64 | |||
| var completionRatio float64 | |||
| var channelPricingFound bool | |||
| var cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio float64 | |||
| // 尝试获取渠道定价(优先于全局定价) | |||
| // ChannelMeta 在 InitChannelMeta 之前为 nil,但 Distribute 已将 channelId 写入 context | |||
| channelId := 0 | |||
| if info != nil && info.ChannelMeta != nil && info.ChannelId > 0 { | |||
| channelId = info.ChannelId | |||
| } else { | |||
| channelId = common.GetContextKeyInt(c, constant.ContextKeyChannelId) | |||
| } | |||
| if channelId > 0 { | |||
| cp, found := model.GetEffectivePricing(info.OriginModelName, channelId) | |||
| if found && cp != nil { | |||
| modelRatio = cp.ModelRatio | |||
| completionRatio = cp.CompletionRatio | |||
| modelPrice = cp.ModelPrice | |||
| usePrice = cp.QuotaType == model.QuotaTypeByCall | |||
| channelPricingFound = true | |||
| // 渠道定价的扩展比率(非零值直接使用) | |||
| cacheRatio = cp.CacheRatio | |||
| cacheCreationRatio = cp.CacheCreationRatio | |||
| imageRatio = cp.ImageRatio | |||
| audioRatio = cp.AudioRatio | |||
| audioCompletionRatio = cp.AudioCompletionRatio | |||
| } | |||
| } | |||
| // 如果没有渠道定价,使用全局定价 | |||
| if !channelPricingFound { | |||
| modelPrice, usePrice = ratio_setting.GetModelPrice(info.OriginModelName, false) | |||
| } | |||
| modelPrice, usePrice = ratio_setting.GetModelPrice(info.OriginModelName, false) | |||
| groupRatioInfo := HandleGroupRatio(c, info) | |||
| var preConsumedQuota int | |||
| @@ -91,22 +56,21 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||
| if meta.MaxTokens != 0 { | |||
| preConsumedTokens += meta.MaxTokens | |||
| } | |||
| // 如果没有找到渠道定价,获取全局 modelRatio 和 completionRatio | |||
| if !channelPricingFound { | |||
| var success bool | |||
| var matchName string | |||
| modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName) | |||
| if !success { | |||
| acceptUnsetRatio := false | |||
| if info.UserSetting.AcceptUnsetRatioModel { | |||
| acceptUnsetRatio = true | |||
| } | |||
| if !acceptUnsetRatio { | |||
| return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开启自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) | |||
| } | |||
| var success bool | |||
| var matchName string | |||
| modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName) | |||
| if !success { | |||
| acceptUnsetRatio := false | |||
| if info.UserSetting.AcceptUnsetRatioModel { | |||
| acceptUnsetRatio = true | |||
| } | |||
| if !acceptUnsetRatio { | |||
| return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开启自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) | |||
| } | |||
| completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) | |||
| } | |||
| completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) | |||
| ratio := modelRatio * groupRatioInfo.GroupRatio | |||
| preConsumedQuota = int(float64(preConsumedTokens) * ratio) | |||
| } else { | |||
| @@ -116,27 +80,13 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||
| preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) | |||
| } | |||
| // 全局比率作为回退(仅当渠道未设置、值为 0 时生效) | |||
| // 必须放在 usePrice 判断之外,因为 UpdatePriceDataForChannelPricing 可能改变 UsePrice | |||
| if cacheRatio == 0 { | |||
| cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName) | |||
| } | |||
| if cacheCreationRatio == 0 { | |||
| cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName) | |||
| } | |||
| if imageRatio == 0 { | |||
| imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) | |||
| } | |||
| if audioRatio == 0 { | |||
| audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) | |||
| } | |||
| if audioCompletionRatio == 0 { | |||
| audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) | |||
| } | |||
| cacheRatio, _ := ratio_setting.GetCacheRatio(info.OriginModelName) | |||
| cacheCreationRatio, _ := ratio_setting.GetCreateCacheRatio(info.OriginModelName) | |||
| imageRatio, _ := ratio_setting.GetImageRatio(info.OriginModelName) | |||
| audioRatio := ratio_setting.GetAudioRatio(info.OriginModelName) | |||
| audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(info.OriginModelName) | |||
| // check if free model pre-consume is disabled | |||
| if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { | |||
| // if model price or ratio is 0, do not pre-consume quota | |||
| if groupRatioInfo.GroupRatio == 0 { | |||
| preConsumedQuota = 0 | |||
| freeModel = true | |||
| @@ -145,11 +95,9 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||
| preConsumedQuota = 0 | |||
| freeModel = true | |||
| } | |||
| } else { | |||
| if modelRatio == 0 { | |||
| preConsumedQuota = 0 | |||
| freeModel = true | |||
| } | |||
| } else if modelRatio == 0 { | |||
| preConsumedQuota = 0 | |||
| freeModel = true | |||
| } | |||
| } | |||
| @@ -183,7 +131,6 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. | |||
| groupRatioInfo := HandleGroupRatio(c, info) | |||
| modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) | |||
| // 如果没有配置价格,则使用默认价格 | |||
| if !success { | |||
| defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] | |||
| if !ok { | |||
| @@ -194,7 +141,6 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. | |||
| } | |||
| quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) | |||
| // 免费模型检测(与 ModelPriceHelper 对齐) | |||
| freeModel := false | |||
| if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { | |||
| if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { | |||
| @@ -204,11 +150,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. | |||
| } | |||
| priceData := types.PriceData{ | |||
| FreeModel: freeModel, | |||
| ModelPrice: modelPrice, | |||
| Quota: quota, | |||
| GroupRatioInfo: groupRatioInfo, | |||
| UserChannelRatio: 1.0, | |||
| FreeModel: freeModel, | |||
| ModelPrice: modelPrice, | |||
| Quota: quota, | |||
| GroupRatioInfo: groupRatioInfo, | |||
| UserChannelRatio: 1.0, | |||
| } | |||
| return priceData | |||
| } | |||
| @@ -225,45 +171,11 @@ func ContainPriceOrRatio(modelName string) bool { | |||
| return false | |||
| } | |||
| // UpdatePriceDataForChannelPricing 在渠道选择后更新 PriceData 以使用渠道定价 | |||
| // ModelPriceHelper 在渠道选择之前被调用,此时 ChannelMeta 为 nil,导致渠道定价无法使用。 | |||
| // 需要在渠道选择后调用此函数更新价格数据,用于后续的日志记录和计费计算。 | |||
| // UpdatePriceDataForChannelPricing 保留原调用点,仅在选定渠道后应用用户渠道倍率。 | |||
| func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInfo, channelId int) { | |||
| if info == nil || channelId <= 0 { | |||
| return | |||
| } | |||
| cp, found := model.GetEffectivePricing(info.OriginModelName, channelId) | |||
| if found { | |||
| info.PriceData.ModelRatio = cp.ModelRatio | |||
| info.PriceData.CompletionRatio = cp.CompletionRatio | |||
| info.PriceData.UsePrice = cp.QuotaType == model.QuotaTypeByCall | |||
| if info.PriceData.UsePrice { | |||
| info.PriceData.ModelPrice = cp.ModelPrice | |||
| } else { | |||
| info.PriceData.ModelPrice = -1 | |||
| } | |||
| info.PriceData.ApplyChannelPricingRatios(cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio) | |||
| if info.PriceData.UsePrice { | |||
| info.PriceData.QuotaToPreConsume = int( | |||
| cp.ModelPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio) | |||
| } else { | |||
| estimateTokens := info.GetEstimatePromptTokens() | |||
| if estimateTokens > 0 { | |||
| ratio := cp.ModelRatio * info.PriceData.GroupRatioInfo.GroupRatio | |||
| info.PriceData.QuotaToPreConsume = int(float64(estimateTokens) * ratio) | |||
| } | |||
| } | |||
| if common.DebugEnabled { | |||
| println(fmt.Sprintf("[ChannelPricing] updatePriceData: model=%s channel=%d modelRatio=%.4f completionRatio=%.4f cacheRatio=%.4f imageRatio=%.4f audioRatio=%.4f", | |||
| info.OriginModelName, channelId, cp.ModelRatio, cp.CompletionRatio, cp.CacheRatio, cp.ImageRatio, cp.AudioRatio)) | |||
| } | |||
| } | |||
| // 应用用户-模型-渠道倍率(始终执行,不依赖渠道定价是否存在) | |||
| ApplyUserChannelRatio(c, info, channelId) | |||
| } | |||
| @@ -6,13 +6,14 @@ import ( | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/constant" | |||
| "github.com/QuantumNous/new-api/dto" | |||
| "github.com/QuantumNous/new-api/model" | |||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | |||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||
| "github.com/QuantumNous/new-api/types" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/assert" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| @@ -23,54 +24,44 @@ const testModelUsePriceSwitch = "test-useprice-switch-model" | |||
| func setupUsePriceSwitchTest(t *testing.T) { | |||
| t.Helper() | |||
| // 初始化 SQLite 内存数据库 | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, _ := db.DB() | |||
| sqlDB, err := db.DB() | |||
| require.NoError(t, err) | |||
| sqlDB.SetMaxOpenConns(1) | |||
| origDB := model.DB | |||
| origUsingSQLite := common.UsingSQLite | |||
| origRedisEnabled := common.RedisEnabled | |||
| model.DB = db | |||
| common.UsingSQLite = true | |||
| common.RedisEnabled = false | |||
| require.NoError(t, db.AutoMigrate(&model.ChannelPricing{})) | |||
| require.NoError(t, db.AutoMigrate(&model.UserChannelRatio{})) | |||
| // 全局定价:按次计费(modelPrice 存在 → usePrice=true) | |||
| require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testModelUsePriceSwitch+`":0.5}`)) | |||
| // 全局 modelRatio 也设上(渠道按量计费时会用到) | |||
| require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testModelUsePriceSwitch+`":15}`)) | |||
| require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3}`)) | |||
| // 全局扩展比率 | |||
| require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":0.1}`)) | |||
| require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":1.25}`)) | |||
| require.NoError(t, ratio_setting.UpdateImageRatioByJSONString(`{"`+testModelUsePriceSwitch+`":2.0}`)) | |||
| require.NoError(t, ratio_setting.UpdateAudioRatioByJSONString(`{"`+testModelUsePriceSwitch+`":5.0}`)) | |||
| require.NoError(t, ratio_setting.UpdateAudioCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3.0}`)) | |||
| // 渠道定价:按量计费(QuotaTypeByTokens),扩展比率全部为 0(用全局) | |||
| cp := &model.ChannelPricing{ | |||
| ModelName: testModelUsePriceSwitch, | |||
| ChannelId: 9901, | |||
| QuotaType: model.QuotaTypeByTokens, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 3, | |||
| // 扩展比率全部为 0 → 意味着回退全局 | |||
| } | |||
| require.NoError(t, cp.Insert()) | |||
| t.Cleanup(func() { | |||
| model.DB = origDB | |||
| sqlDB.Close() | |||
| // 清理全局定价 | |||
| ratio_setting.UpdateModelPriceByJSONString(`{}`) | |||
| ratio_setting.UpdateModelRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateCompletionRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateCacheRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateCreateCacheRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateImageRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateAudioRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateAudioCompletionRatioByJSONString(`{}`) | |||
| common.UsingSQLite = origUsingSQLite | |||
| common.RedisEnabled = origRedisEnabled | |||
| require.NoError(t, sqlDB.Close()) | |||
| require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) | |||
| require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`)) | |||
| require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{}`)) | |||
| require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{}`)) | |||
| require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{}`)) | |||
| require.NoError(t, ratio_setting.UpdateImageRatioByJSONString(`{}`)) | |||
| require.NoError(t, ratio_setting.UpdateAudioRatioByJSONString(`{}`)) | |||
| require.NoError(t, ratio_setting.UpdateAudioCompletionRatioByJSONString(`{}`)) | |||
| }) | |||
| } | |||
| @@ -79,88 +70,81 @@ func buildTestContext(t *testing.T) *gin.Context { | |||
| gin.SetMode(gin.TestMode) | |||
| w := httptest.NewRecorder() | |||
| c, _ := gin.CreateTestContext(w) | |||
| c.Request, _ = http.NewRequest("POST", "/v1/chat/completions", nil) | |||
| req, err := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil) | |||
| require.NoError(t, err) | |||
| c.Request = req | |||
| return c | |||
| } | |||
| // TestGlobalRatiosFallbackWhenGlobalUsePriceTrue 验证核心场景: | |||
| // 全局按次计费 → 渠道按量计费 → PriceData 中扩展比率仍应被全局默认值填充 | |||
| // 而不是保持 0(否则后续 UpdatePriceDataForChannelPricing 切换为按量时会丢失比率) | |||
| func TestGlobalRatiosFallbackWhenGlobalUsePriceTrue(t *testing.T) { | |||
| func TestGlobalRatiosFilledWhenUsePriceTrue(t *testing.T) { | |||
| setupUsePriceSwitchTest(t) | |||
| c := buildTestContext(t) | |||
| // 模拟渠道选择后的场景:ChannelMeta 不为空,能查到渠道定价 | |||
| info := &relaycommon.RelayInfo{ | |||
| OriginModelName: testModelUsePriceSwitch, | |||
| ChannelMeta: &relaycommon.ChannelMeta{ | |||
| ChannelId: 9901, | |||
| }, | |||
| UserSetting: dto.UserSetting{}, | |||
| UserSetting: dto.UserSetting{}, | |||
| } | |||
| meta := &types.TokenCountMeta{} | |||
| priceData, err := ModelPriceHelper(c, info, 100, meta) | |||
| priceData, err := ModelPriceHelper(c, info, 100, &types.TokenCountMeta{}) | |||
| require.NoError(t, err) | |||
| // 断言:渠道按量计费覆盖了全局按次,扩展比率必须被全局默认值填充,不能是 0 | |||
| assert.Equal(t, 0.1, priceData.CacheRatio, "CacheRatio 应从全局回退填充") | |||
| assert.Equal(t, 1.25, priceData.CacheCreationRatio, "CacheCreationRatio 应从全局回退填充") | |||
| assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 应从全局回退填充") | |||
| assert.Equal(t, 5.0, priceData.AudioRatio, "AudioRatio 应从全局回退填充") | |||
| assert.Equal(t, 3.0, priceData.AudioCompletionRatio, "AudioCompletionRatio 应从全局回退填充") | |||
| assert.True(t, priceData.UsePrice) | |||
| assert.Equal(t, 0.5, priceData.ModelPrice) | |||
| assert.Equal(t, 0.1, priceData.CacheRatio) | |||
| assert.Equal(t, 1.25, priceData.CacheCreationRatio) | |||
| assert.Equal(t, 2.0, priceData.ImageRatio) | |||
| assert.Equal(t, 5.0, priceData.AudioRatio) | |||
| assert.Equal(t, 3.0, priceData.AudioCompletionRatio) | |||
| } | |||
| // TestGlobalRatiosFallbackWithoutChannelPricing 验证无渠道定价时全局比率也正常回退 | |||
| func TestGlobalRatiosFallbackWithoutChannelPricing(t *testing.T) { | |||
| func TestGlobalRatiosFilledWhenUseRatioMode(t *testing.T) { | |||
| setupUsePriceSwitchTest(t) | |||
| c := buildTestContext(t) | |||
| // 无 ChannelMeta → 渠道定价不可用,走全局 | |||
| require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) | |||
| info := &relaycommon.RelayInfo{ | |||
| OriginModelName: testModelUsePriceSwitch, | |||
| UserSetting: dto.UserSetting{}, | |||
| } | |||
| meta := &types.TokenCountMeta{} | |||
| priceData, err := ModelPriceHelper(c, info, 100, meta) | |||
| priceData, err := ModelPriceHelper(c, info, 100, &types.TokenCountMeta{}) | |||
| require.NoError(t, err) | |||
| // 全局是按次计费,UsePrice=true,但比率仍应被填充 | |||
| assert.True(t, priceData.UsePrice) | |||
| assert.Equal(t, 0.1, priceData.CacheRatio, "CacheRatio 应从全局回退填充") | |||
| assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 应从全局回退填充") | |||
| assert.False(t, priceData.UsePrice) | |||
| assert.Equal(t, 15.0, priceData.ModelRatio) | |||
| assert.Equal(t, 3.0, priceData.CompletionRatio) | |||
| assert.Equal(t, 0.1, priceData.CacheRatio) | |||
| assert.Equal(t, 2.0, priceData.ImageRatio) | |||
| } | |||
| // TestChannelRatiosOverrideGlobal 验证渠道非零值优先于全局 | |||
| func TestChannelRatiosOverrideGlobal(t *testing.T) { | |||
| func TestUpdatePriceDataForChannelPricingOnlyAppliesUserChannelRatio(t *testing.T) { | |||
| setupUsePriceSwitchTest(t) | |||
| // 渠道定价设了自定义 cacheRatio=0.5 | |||
| cp := &model.ChannelPricing{ | |||
| ModelName: testModelUsePriceSwitch, | |||
| ChannelId: 9902, | |||
| QuotaType: model.QuotaTypeByTokens, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 3, | |||
| CacheRatio: 0.5, // 渠道自定义,覆盖全局 0.1 | |||
| // 其他扩展比率保持 0,用全局 | |||
| ratio := &model.UserChannelRatio{ | |||
| UserId: 9, | |||
| ModelName: testModelUsePriceSwitch, | |||
| ChannelId: 9901, | |||
| Ratio: 0.8, | |||
| } | |||
| require.NoError(t, cp.Insert()) | |||
| require.NoError(t, ratio.Insert()) | |||
| c := buildTestContext(t) | |||
| c.Set(string(constant.ContextKeyUserId), 9) | |||
| info := &relaycommon.RelayInfo{ | |||
| OriginModelName: testModelUsePriceSwitch, | |||
| ChannelMeta: &relaycommon.ChannelMeta{ | |||
| ChannelId: 9902, | |||
| PriceData: types.PriceData{ | |||
| ModelPrice: 0.5, | |||
| UsePrice: true, | |||
| QuotaToPreConsume: 1000, | |||
| UserChannelRatio: 1.0, | |||
| }, | |||
| UserSetting: dto.UserSetting{}, | |||
| } | |||
| priceData, err := ModelPriceHelper(c, info, 100, &types.TokenCountMeta{}) | |||
| require.NoError(t, err) | |||
| UpdatePriceDataForChannelPricing(c, info, 9901) | |||
| assert.Equal(t, 0.5, priceData.CacheRatio, "渠道自定义 CacheRatio 应覆盖全局") | |||
| assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 未设渠道值,应从全局回退") | |||
| assert.Equal(t, 5.0, priceData.AudioRatio, "AudioRatio 未设渠道值,应从全局回退") | |||
| assert.Equal(t, 0.8, info.PriceData.UserChannelRatio) | |||
| assert.Equal(t, 800, info.PriceData.QuotaToPreConsume) | |||
| assert.Equal(t, 0.5, info.PriceData.ModelPrice) | |||
| } | |||
| @@ -33,7 +33,6 @@ func SetApiRouter(router *gin.Engine) { | |||
| apiRouter.GET("/home_page_content", controller.GetHomePageContent) | |||
| apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) | |||
| apiRouter.GET("/pricing/user/*model", middleware.TryUserAuth(), controller.GetUserPricing) | |||
| apiRouter.GET("/channel-pricing/model/*name", middleware.TryUserAuth(), controller.GetChannelPricingByModelWithChannelInfo) | |||
| apiRouter.GET("/captcha", controller.GetCaptcha) | |||
| apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) | |||
| apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) | |||
| @@ -192,19 +191,6 @@ func SetApiRouter(router *gin.Engine) { | |||
| optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 | |||
| } | |||
| // 渠道定价路由(管理员权限) | |||
| channelPricingRoute := apiRouter.Group("/channel_pricing") | |||
| channelPricingRoute.Use(middleware.AdminAuth()) | |||
| { | |||
| channelPricingRoute.GET("/", controller.GetAllChannelPricing) | |||
| channelPricingRoute.GET("/with_tags", controller.GetChannelPricingWithTags) | |||
| channelPricingRoute.GET("/model/:name", controller.GetChannelPricingByModel) | |||
| channelPricingRoute.POST("/", controller.CreateChannelPricing) | |||
| channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing) | |||
| channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing) | |||
| channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing) | |||
| } | |||
| // 用户倍率路由(管理员权限) | |||
| userChannelRatioRoute := apiRouter.Group("/user_channel_ratio") | |||
| userChannelRatioRoute.Use(middleware.AdminAuth()) | |||
| @@ -215,16 +201,6 @@ func SetApiRouter(router *gin.Engine) { | |||
| userChannelRatioRoute.DELETE("/:id", controller.DeleteUserChannelRatio) | |||
| } | |||
| // 定价标签路由(管理员权限) | |||
| pricingTagRoute := apiRouter.Group("/pricing_tag") | |||
| pricingTagRoute.Use(middleware.AdminAuth()) | |||
| { | |||
| pricingTagRoute.GET("/", controller.GetAllPricingTags) | |||
| pricingTagRoute.POST("/", controller.CreatePricingTag) | |||
| pricingTagRoute.PUT("/:id", controller.UpdatePricingTag) | |||
| pricingTagRoute.DELETE("/:id", controller.DeletePricingTag) | |||
| } | |||
| // 邮箱后缀额度规则路由(管理员权限) | |||
| emailQuotaRuleRoute := apiRouter.Group("/email_quota_rule") | |||
| emailQuotaRuleRoute.Use(middleware.AdminAuth()) | |||