Просмотр исходного кода

fix(pricing): remove stale channel pricing backend

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
fengsilin 1 неделю назад
Родитель
Сommit
2af3a9301d
5 измененных файлов: 88 добавлений и 706 удалений
  1. +0
    -359
      controller/channel_pricing.go
  2. +0
    -131
      controller/pricing_tag.go
  3. +29
    -117
      relay/helper/price.go
  4. +59
    -75
      relay/helper/price_test.go
  5. +0
    -24
      router/api-router.go

+ 0
- 359
controller/channel_pricing.go Просмотреть файл

@@ -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,
})
}


+ 0
- 131
controller/pricing_tag.go Просмотреть файл

@@ -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)
}

+ 29
- 117
relay/helper/price.go Просмотреть файл

@@ -18,26 +18,22 @@ import (
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present // 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 { func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.GroupRatioInfo {
groupRatioInfo := types.GroupRatioInfo{ groupRatioInfo := types.GroupRatioInfo{
GroupRatio: 1.0, // default ratio
GroupRatio: 1.0,
GroupSpecialRatio: -1, GroupSpecialRatio: -1,
} }


// check auto group
autoGroup, exists := ctx.Get("auto_group") autoGroup, exists := ctx.Get("auto_group")
if exists { if exists {
logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup)) logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup))
relayInfo.UsingGroup = autoGroup.(string) relayInfo.UsingGroup = autoGroup.(string)
} }


// check user group special ratio
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
if ok { if ok {
// user group special ratio
groupRatioInfo.GroupSpecialRatio = userGroupRatio groupRatioInfo.GroupSpecialRatio = userGroupRatio
groupRatioInfo.GroupRatio = userGroupRatio groupRatioInfo.GroupRatio = userGroupRatio
groupRatioInfo.HasSpecialRatio = true groupRatioInfo.HasSpecialRatio = true
} else { } else {
// normal group ratio
groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup) groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
} }


@@ -49,39 +45,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var usePrice bool var usePrice bool
var modelRatio float64 var modelRatio float64
var completionRatio 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) groupRatioInfo := HandleGroupRatio(c, info)


var preConsumedQuota int var preConsumedQuota int
@@ -91,22 +56,21 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
if meta.MaxTokens != 0 { if meta.MaxTokens != 0 {
preConsumedTokens += meta.MaxTokens 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 ratio := modelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio) preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else { } else {
@@ -116,27 +80,13 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) 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 !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
// if model price or ratio is 0, do not pre-consume quota
if groupRatioInfo.GroupRatio == 0 { if groupRatioInfo.GroupRatio == 0 {
preConsumedQuota = 0 preConsumedQuota = 0
freeModel = true freeModel = true
@@ -145,11 +95,9 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
preConsumedQuota = 0 preConsumedQuota = 0
freeModel = true 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) groupRatioInfo := HandleGroupRatio(c, info)


modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
// 如果没有配置价格,则使用默认价格
if !success { if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if !ok { if !ok {
@@ -194,7 +141,6 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.
} }
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)


// 免费模型检测(与 ModelPriceHelper 对齐)
freeModel := false freeModel := false
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
@@ -204,11 +150,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.
} }


priceData := types.PriceData{ 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 return priceData
} }
@@ -225,45 +171,11 @@ func ContainPriceOrRatio(modelName string) bool {
return false return false
} }


// UpdatePriceDataForChannelPricing 在渠道选择后更新 PriceData 以使用渠道定价
// ModelPriceHelper 在渠道选择之前被调用,此时 ChannelMeta 为 nil,导致渠道定价无法使用。
// 需要在渠道选择后调用此函数更新价格数据,用于后续的日志记录和计费计算。
// UpdatePriceDataForChannelPricing 保留原调用点,仅在选定渠道后应用用户渠道倍率。
func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInfo, channelId int) { func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInfo, channelId int) {
if info == nil || channelId <= 0 { if info == nil || channelId <= 0 {
return 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) ApplyUserChannelRatio(c, info, channelId)
} }




+ 59
- 75
relay/helper/price_test.go Просмотреть файл

@@ -6,13 +6,14 @@ import (
"testing" "testing"


"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
"github.com/glebarez/sqlite"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
@@ -23,54 +24,44 @@ const testModelUsePriceSwitch = "test-useprice-switch-model"
func setupUsePriceSwitchTest(t *testing.T) { func setupUsePriceSwitchTest(t *testing.T) {
t.Helper() t.Helper()


// 初始化 SQLite 内存数据库
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err) require.NoError(t, err)
sqlDB, _ := db.DB()

sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxOpenConns(1)


origDB := model.DB origDB := model.DB
origUsingSQLite := common.UsingSQLite
origRedisEnabled := common.RedisEnabled
model.DB = db model.DB = db
common.UsingSQLite = true common.UsingSQLite = true
common.RedisEnabled = false 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}`)) require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testModelUsePriceSwitch+`":0.5}`))
// 全局 modelRatio 也设上(渠道按量计费时会用到)
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testModelUsePriceSwitch+`":15}`)) require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testModelUsePriceSwitch+`":15}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3}`)) require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3}`))
// 全局扩展比率
require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":0.1}`)) require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":0.1}`))
require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":1.25}`)) require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":1.25}`))
require.NoError(t, ratio_setting.UpdateImageRatioByJSONString(`{"`+testModelUsePriceSwitch+`":2.0}`)) require.NoError(t, ratio_setting.UpdateImageRatioByJSONString(`{"`+testModelUsePriceSwitch+`":2.0}`))
require.NoError(t, ratio_setting.UpdateAudioRatioByJSONString(`{"`+testModelUsePriceSwitch+`":5.0}`)) require.NoError(t, ratio_setting.UpdateAudioRatioByJSONString(`{"`+testModelUsePriceSwitch+`":5.0}`))
require.NoError(t, ratio_setting.UpdateAudioCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3.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() { t.Cleanup(func() {
model.DB = origDB 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) gin.SetMode(gin.TestMode)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) 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 return c
} }


// TestGlobalRatiosFallbackWhenGlobalUsePriceTrue 验证核心场景:
// 全局按次计费 → 渠道按量计费 → PriceData 中扩展比率仍应被全局默认值填充
// 而不是保持 0(否则后续 UpdatePriceDataForChannelPricing 切换为按量时会丢失比率)
func TestGlobalRatiosFallbackWhenGlobalUsePriceTrue(t *testing.T) {
func TestGlobalRatiosFilledWhenUsePriceTrue(t *testing.T) {
setupUsePriceSwitchTest(t) setupUsePriceSwitchTest(t)
c := buildTestContext(t) c := buildTestContext(t)


// 模拟渠道选择后的场景:ChannelMeta 不为空,能查到渠道定价
info := &relaycommon.RelayInfo{ info := &relaycommon.RelayInfo{
OriginModelName: testModelUsePriceSwitch, 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) 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) setupUsePriceSwitchTest(t)
c := buildTestContext(t) c := buildTestContext(t)


// 无 ChannelMeta → 渠道定价不可用,走全局
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`))

info := &relaycommon.RelayInfo{ info := &relaycommon.RelayInfo{
OriginModelName: testModelUsePriceSwitch, OriginModelName: testModelUsePriceSwitch,
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) 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) 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 := buildTestContext(t)
c.Set(string(constant.ContextKeyUserId), 9)

info := &relaycommon.RelayInfo{ info := &relaycommon.RelayInfo{
OriginModelName: testModelUsePriceSwitch, 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)
} }

+ 0
- 24
router/api-router.go Просмотреть файл

@@ -33,7 +33,6 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/home_page_content", controller.GetHomePageContent)
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
apiRouter.GET("/pricing/user/*model", middleware.TryUserAuth(), controller.GetUserPricing) 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("/captcha", controller.GetCaptcha)
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) 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) // 用于迁移检测的旧键,下个版本会删除 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 := apiRouter.Group("/user_channel_ratio")
userChannelRatioRoute.Use(middleware.AdminAuth()) userChannelRatioRoute.Use(middleware.AdminAuth())
@@ -215,16 +201,6 @@ func SetApiRouter(router *gin.Engine) {
userChannelRatioRoute.DELETE("/:id", controller.DeleteUserChannelRatio) 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 := apiRouter.Group("/email_quota_rule")
emailQuotaRuleRoute.Use(middleware.AdminAuth()) emailQuotaRuleRoute.Use(middleware.AdminAuth())


Загрузка…
Отмена
Сохранить