Kaynağa Gözat

Merge branch 'feat/remove-channel-pricing'

# Conflicts:
#	model/pricing_test.go
master
fengsilin 1 hafta önce
ebeveyn
işleme
3a6dec8a6c
9 değiştirilmiş dosya ile 200 ekleme ve 545 silme
  1. +2
    -0
      .gitignore
  2. +5
    -6
      controller/pricing.go
  3. +0
    -330
      model/channel_pricing.go
  4. +13
    -12
      model/main.go
  5. +28
    -131
      model/pricing.go
  6. +0
    -60
      model/pricing_tag.go
  7. +96
    -6
      model/pricing_test.go
  8. +45
    -0
      model/remove_channel_pricing_migration_test.go
  9. +11
    -0
      web/dist/index.html

+ 2
- 0
.gitignore Dosyayı Görüntüle

@@ -8,6 +8,8 @@ build
*.db-journal
logs
web/dist
!web/dist/
!web/dist/index.html
.env
one-api
new-api


+ 5
- 6
controller/pricing.go Dosyayı Görüntüle

@@ -177,15 +177,14 @@ func formatDiscount(ratio float64) string {
if ratio <= 0 {
return "免费"
}
discount := int(math.Round(ratio * 10))
if discount >= 10 {
rawDiscount := math.Round(ratio*100) / 10
if rawDiscount >= 10 {
return ""
}
remainder := int(math.Round(ratio*100)) % 10
if remainder == 0 {
return fmt.Sprintf("%d折", discount)
if rawDiscount == math.Trunc(rawDiscount) {
return fmt.Sprintf("%.0f折", rawDiscount)
}
return fmt.Sprintf("%.1f折", ratio*10)
return fmt.Sprintf("%.1f折", rawDiscount)
}

func ResetModelRatio(c *gin.Context) {


+ 0
- 330
model/channel_pricing.go Dosyayı Görüntüle

@@ -1,330 +0,0 @@
package model

import (
"fmt"
"strconv"
"strings"
"sync"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)

// 渠道定价缓存
var (
channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId"
channelPricingCacheLock sync.RWMutex
)

// QuotaType 计费类型
const (
QuotaTypeByTokens = 0 // 按量计费
QuotaTypeByCall = 1 // 按次计费
)

// ChannelPricing 渠道定价表
// 支持同一模型在不同渠道设置不同价格
type ChannelPricing struct {
Id int `json:"id" gorm:"primaryKey"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:idx_model_channel,priority:1"`
ChannelId int `json:"channel_id" gorm:"not null;uniqueIndex:idx_model_channel,priority:2;index"`
QuotaType int `json:"quota_type" gorm:"default:0"` // 0=按量, 1=按次
ModelRatio float64 `json:"model_ratio" gorm:"default:0"`
CompletionRatio float64 `json:"completion_ratio" gorm:"default:0"`
ModelPrice float64 `json:"model_price" gorm:"default:0"`
TagIds string `json:"tag_ids" gorm:"type:varchar(255)"` // 逗号分隔的标签ID
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// === 新增字段(0 = 未设置,回退全局值) ===
CacheRatio float64 `json:"cache_ratio" gorm:"default:0"`
CacheCreationRatio float64 `json:"cache_creation_ratio" gorm:"default:0"`
ImageRatio float64 `json:"image_ratio" gorm:"default:0"`
AudioRatio float64 `json:"audio_ratio" gorm:"default:0"`
AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"`
IsDefault bool `json:"is_default" gorm:"default:false;index"`
}

// setCache 写穿透缓存
func setCache(key string, cp *ChannelPricing) {
channelPricingCacheLock.Lock()
channelPricingCache[key] = cp
channelPricingCacheLock.Unlock()
}

func removeCache(key string) {
channelPricingCacheLock.Lock()
delete(channelPricingCache, key)
channelPricingCacheLock.Unlock()
}

// ApplyFields 批量设置定价字段(消除 controller 层的重复赋值)
func (cp *ChannelPricing) ApplyFields(quotaType int, modelRatio, completionRatio, modelPrice float64, tagIds string, cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio float64) {
cp.QuotaType = quotaType
cp.ModelRatio = modelRatio
cp.CompletionRatio = completionRatio
cp.ModelPrice = modelPrice
cp.TagIds = tagIds
cp.CacheRatio = cacheRatio
cp.CacheCreationRatio = cacheCreationRatio
cp.ImageRatio = imageRatio
cp.AudioRatio = audioRatio
cp.AudioCompletionRatio = audioCompletionRatio
}

func (cp *ChannelPricing) Insert() error {
now := common.GetTimestamp()
cp.CreatedTime = now
cp.UpdatedTime = now
err := DB.Create(cp).Error
if err == nil {
setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp)
}
return err
}

func (cp *ChannelPricing) Update() error {
cp.UpdatedTime = common.GetTimestamp()
err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id).
Select("quota_type", "model_ratio", "completion_ratio", "model_price",
"tag_ids", "cache_ratio", "cache_creation_ratio", "image_ratio",
"audio_ratio", "audio_completion_ratio", "is_default", "updated_time").
Updates(cp).Error
if err == nil {
setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp)
}
return err
}

func (cp *ChannelPricing) Delete() error {
var existing ChannelPricing
if err := DB.First(&existing, cp.Id).Error; err != nil {
return err
}
err := DB.Delete(cp).Error
if err == nil {
removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId))
}
return err
}

// GetChannelPricing 获取指定模型在指定渠道的定价
func GetChannelPricing(modelName string, channelId int) (*ChannelPricing, error) {
var cp ChannelPricing
err := DB.Where("model_name = ? AND channel_id = ?", modelName, channelId).First(&cp).Error
if err != nil {
return nil, err
}
return &cp, nil
}

// GetChannelPricingByModel 获取指定模型的所有渠道定价
func GetChannelPricingByModel(modelName string) ([]*ChannelPricing, error) {
var list []*ChannelPricing
err := DB.Where("model_name = ?", modelName).Find(&list).Error
return list, err
}

// GetAllChannelPricing 获取所有渠道定价(分页)
func GetAllChannelPricing(offset int, limit int) ([]*ChannelPricing, int64, error) {
var list []*ChannelPricing
var total int64
if err := DB.Model(&ChannelPricing{}).Count(&total).Error; err != nil {
return nil, 0, err
}
err := DB.Order("id DESC").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}

// BatchUpsertChannelPricing 批量创建或更新渠道定价
func BatchUpsertChannelPricing(pricings []*ChannelPricing) error {
if len(pricings) == 0 {
return nil
}
now := common.GetTimestamp()
for _, cp := range pricings {
cp.UpdatedTime = now
// 仅在 CreatedTime 为空时设置(新记录)
if cp.CreatedTime == 0 {
cp.CreatedTime = now
}
}
// 使用 GORM 的 OnConflict 实现 upsert
// 唯一索引为 idx_model_channel (model_name, channel_id)
err := DB.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "model_name"},
{Name: "channel_id"},
},
DoUpdates: clause.AssignmentColumns([]string{
"quota_type",
"model_ratio",
"completion_ratio",
"model_price",
"tag_ids",
"cache_ratio",
"cache_creation_ratio",
"image_ratio",
"audio_ratio",
"audio_completion_ratio",
"updated_time",
}),
}).Create(&pricings).Error
if err == nil {
for _, cp := range pricings {
setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp)
}
}
return err
}

// getChannelPricingCacheKey 生成缓存键
func getChannelPricingCacheKey(modelName string, channelId int) string {
return fmt.Sprintf("%s:%d", modelName, channelId)
}

// GetEffectivePricing 获取有效定价(纯内存查找)
func GetEffectivePricing(modelName string, channelId int) (*ChannelPricing, bool) {
key := getChannelPricingCacheKey(modelName, channelId)
channelPricingCacheLock.RLock()
cp, ok := channelPricingCache[key]
channelPricingCacheLock.RUnlock()
if !ok {
return nil, false
}
return cp, true
}

// ParseTagIds 解析逗号分隔的标签ID字符串为 PricingTag 切片
func ParseTagIds(tagIds string, tagMap map[int]*PricingTag) []*PricingTag {
if tagIds == "" {
return nil
}
tags := make([]*PricingTag, 0)
for _, idStr := range strings.Split(tagIds, ",") {
if id, err := strconv.Atoi(strings.TrimSpace(idStr)); err == nil {
if tag, ok := tagMap[id]; ok {
tags = append(tags, tag)
}
}
}
return tags
}

// LoadChannelPricingCache 全量加载渠道定价到内存(启动时调用)
func LoadChannelPricingCache() {
var pricings []*ChannelPricing
if err := DB.Find(&pricings).Error; err != nil {
common.SysError("[ChannelPricing] LoadChannelPricingCache failed: " + err.Error())
return
}
channelPricingCacheLock.Lock()
channelPricingCache = make(map[string]*ChannelPricing, len(pricings))
for _, cp := range pricings {
key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId)
channelPricingCache[key] = cp
}
channelPricingCacheLock.Unlock()

common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings)))
}

// ChannelPricingWithChannel 带渠道信息的定价响应
type ChannelPricingWithChannel struct {
Id int `json:"id"`
ChannelId int `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelPublicName string `json:"channel_public_name"`
ChannelType int `json:"channel_type"`
TagIds string `json:"tag_ids" gorm:"column:tag_ids"` // 渠道定价的标签ID列表(逗号分隔)
Tags []*PricingTag `json:"tags" gorm:"-"` // 渠道定价的标签详情(不参与数据库扫描)
QuotaType int `json:"quota_type"` // 0=按量, 1=按次
ModelRatio float64 `json:"model_ratio"`
CompletionRatio float64 `json:"completion_ratio"`
ModelPrice float64 `json:"model_price"`
HasCustomPricing bool `json:"has_custom_pricing"` // 是否有自定义定价
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"`
IsDefault bool `json:"is_default"`
UserRatio float64 `json:"user_ratio"`
}

// GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息)
// 返回所有支持该模型的渠道,对于没有渠道定价的渠道使用全局默认价格
func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricingWithChannel, error) {
var results []*ChannelPricingWithChannel

// 获取全局默认价格
globalModelRatio, hasRatio, _ := ratio_setting.GetModelRatio(modelName)
globalModelPrice, hasPrice := ratio_setting.GetModelPrice(modelName, false)
globalCompletionRatio := ratio_setting.GetCompletionRatio(modelName)

// 确定默认计费类型
var defaultQuotaType int
if hasPrice {
defaultQuotaType = QuotaTypeByCall
} else {
defaultQuotaType = QuotaTypeByTokens
}

// 如果没有全局价格,设置默认值
if !hasRatio {
globalModelRatio = 0
}
if !hasPrice {
globalModelPrice = 0
}
// 查询所有支持该模型的渠道,左连接渠道定价表
// 高级字段(cache/image/audio)不回退全局值,直接返回 0
err := DB.Table("abilities").
Select(`abilities.channel_id, channels.name as channel_name, channels.public_name as channel_public_name, channels.type as channel_type,
COALESCE(channel_pricings.quota_type, ?) as quota_type,
COALESCE(channel_pricings.model_ratio, ?) as model_ratio,
COALESCE(channel_pricings.completion_ratio, ?) as completion_ratio,
COALESCE(channel_pricings.model_price, ?) as model_price,
channel_pricings.id as id,
channel_pricings.tag_ids as tag_ids,
COALESCE(channel_pricings.cache_ratio, 0) as cache_ratio,
COALESCE(channel_pricings.cache_creation_ratio, 0) as cache_creation_ratio,
COALESCE(channel_pricings.image_ratio, 0) as image_ratio,
COALESCE(channel_pricings.audio_ratio, 0) as audio_ratio,
COALESCE(channel_pricings.audio_completion_ratio, 0) as audio_completion_ratio,
COALESCE(channel_pricings.is_default, false) as is_default,
(channel_pricings.id IS NOT NULL) as has_custom_pricing`,
defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice).
Joins("LEFT JOIN channels ON abilities.channel_id = channels.id").
Joins("LEFT JOIN channel_pricings ON abilities.channel_id = channel_pricings.channel_id AND channel_pricings.model_name = ? AND channel_pricings.deleted_at IS NULL", modelName).
Where("abilities.model = ?", modelName).
Where("abilities.enabled = ?", true).
Where("channels.status = ?", 1). // 只显示启用的渠道
Group("abilities.channel_id, channels.name, channels.public_name, channels.type, channel_pricings.quota_type, channel_pricings.model_ratio, channel_pricings.completion_ratio, channel_pricings.model_price, channel_pricings.id, channel_pricings.tag_ids, channel_pricings.cache_ratio, channel_pricings.cache_creation_ratio, channel_pricings.image_ratio, channel_pricings.audio_ratio, channel_pricings.audio_completion_ratio, channel_pricings.is_default").
Scan(&results).Error
if err != nil {
return nil, err
}

// 获取所有定价标签
allTags, err := GetAllPricingTags()
if err != nil {
return results, nil // 如果获取标签失败,仍然返回基础结果
}

// 建立标签ID到标签的映射
tagMap := make(map[int]*PricingTag)
for _, tag := range allTags {
tagMap[tag.Id] = tag
}

// 为每个渠道定价填充标签
for _, result := range results {
result.Tags = ParseTagIds(result.TagIds, tagMap)
}

return results, nil
}


+ 13
- 12
model/main.go Dosyayı Görüntüle

@@ -207,7 +207,6 @@ func InitDB() (err error) {
return err
}
LoadEmailQuotaCache()
LoadChannelPricingCache()
LoadUserChannelRatioCache()
return nil
} else {
@@ -284,8 +283,6 @@ func migrateDB() error {
&SubscriptionPreConsumeRecord{},
&CustomOAuthProvider{},
&UserOAuthBinding{},
&ChannelPricing{},
&PricingTag{},
&UserChannelRatio{},
&PendingSyncRecord{},
&QuotaSyncLog{},
@@ -295,6 +292,12 @@ func migrateDB() error {
if err != nil {
return err
}
if err := DB.Exec("DROP TABLE IF EXISTS channel_pricings").Error; err != nil {
return err
}
if err := DB.Exec("DROP TABLE IF EXISTS pricing_tags").Error; err != nil {
return err
}
if common.UsingSQLite {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
@@ -303,14 +306,14 @@ func migrateDB() error {
if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
return err
}
}
// 将现有 sort_order=0 的模型和供应商更新为默认大数
DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999)
DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999)
migrateChannelPublicName()

return nil
}
// 将现有 sort_order=0 的模型和供应商更新为默认大数
DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999)
DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999)
migrateChannelPublicName()

return nil
}

func migrateDBFast() error {
// Drop bound_channel_id column from tokens table (deprecated field)
@@ -346,8 +349,6 @@ func migrateDBFast() error {
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&ChannelPricing{}, "ChannelPricing"},
{&PricingTag{}, "PricingTag"},
{&UserChannelRatio{}, "UserChannelRatio"},
{&PendingSyncRecord{}, "PendingSyncRecord"},
{&QuotaSyncLog{}, "QuotaSyncLog"},


+ 28
- 131
model/pricing.go Dosyayı Görüntüle

@@ -1,11 +1,9 @@
package model

import (
"encoding/json"
"fmt"
"sort"
"strings"

"sync"
"time"

@@ -41,6 +39,11 @@ type PricingVendor struct {
Icon string `json:"icon,omitempty"`
}

const (
QuotaTypeByTokens = 0
QuotaTypeByCall = 1
)

var (
pricingMap []Pricing
pricingByModel map[string]*Pricing
@@ -49,7 +52,6 @@ var (
lastGetPricingTime time.Time
updatePricingLock sync.Mutex

// 缓存映射:模型名 -> 启用分组 / 计费类型
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
modelEnableGroupsLock = sync.RWMutex{}
@@ -60,7 +62,6 @@ var (
modelSupportEndpointsLock = sync.RWMutex{}
)

// SetTestPricing 设置测试用的定价缓存(仅用于测试)
func SetTestPricing(pricing []Pricing) {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
@@ -74,11 +75,10 @@ func SetTestPricing(pricing []Pricing) {
}

func GetPricing() []Pricing {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
if time.Since(lastGetPricingTime) > time.Minute || len(pricingMap) == 0 {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
// Double check after acquiring the lock
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
if time.Since(lastGetPricingTime) > time.Minute || len(pricingMap) == 0 {
modelSupportEndpointsLock.Lock()
defer modelSupportEndpointsLock.Unlock()
updatePricing()
@@ -87,19 +87,16 @@ func GetPricing() []Pricing {
return pricingMap
}

// GetPricingByModel 从缓存中查找指定模型的定价信息
func GetPricingByModel(modelName string) *Pricing {
GetPricing() // 确保缓存已刷新
GetPricing()
modelEnableGroupsLock.RLock()
p := pricingByModel[modelName]
modelEnableGroupsLock.RUnlock()
return p
}

// GetVendors 返回当前定价接口使用到的供应商信息
func GetVendors() []PricingVendor {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
// 保证先刷新一次
if time.Since(lastGetPricingTime) > time.Minute || len(pricingMap) == 0 {
GetPricing()
}
return vendorsList
@@ -118,13 +115,12 @@ func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
}

func updatePricing() {
//modelRatios := common.GetModelRatios()
enableAbilities, err := GetAllEnableAbilityWithChannels()
if err != nil {
common.SysLog(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err))
return
}
// 预加载模型元数据与供应商一次,避免循环查询
var allMeta []Model
_ = DB.Find(&allMeta).Error
metaMap := make(map[string]*Model)
@@ -133,21 +129,18 @@ func updatePricing() {
containsList := make([]*Model, 0)
for i := range allMeta {
m := &allMeta[i]
if m.NameRule == NameRuleExact {
switch m.NameRule {
case NameRuleExact:
metaMap[m.ModelName] = m
} else {
switch m.NameRule {
case NameRulePrefix:
prefixList = append(prefixList, m)
case NameRuleSuffix:
suffixList = append(suffixList, m)
case NameRuleContains:
containsList = append(containsList, m)
}
case NameRulePrefix:
prefixList = append(prefixList, m)
case NameRuleSuffix:
suffixList = append(suffixList, m)
case NameRuleContains:
containsList = append(containsList, m)
}
}

// 将非精确规则模型匹配到 metaMap
for _, m := range prefixList {
for _, pricingModel := range enableAbilities {
if strings.HasPrefix(pricingModel.Model, m.ModelName) {
@@ -176,7 +169,6 @@ func updatePricing() {
}
}

// 预加载供应商
var vendors []Vendor
_ = DB.Find(&vendors).Error
vendorMap := make(map[int]*Vendor)
@@ -184,10 +176,8 @@ func updatePricing() {
vendorMap[vendors[i].Id] = &vendors[i]
}

// 初始化默认供应商映射
initDefaultVendorMapping(metaMap, vendorMap, enableAbilities)

// 构建对前端友好的供应商列表
vendorOrderMap := make(map[int]int)
for _, v := range vendorMap {
vendorOrderMap[v.Id] = v.SortOrder
@@ -212,7 +202,6 @@ func updatePricing() {
})

modelGroupsMap := make(map[string]*types.Set[string])

for _, ability := range enableAbilities {
groups, ok := modelGroupsMap[ability.Model]
if !ok {
@@ -222,10 +211,7 @@ func updatePricing() {
groups.Add(ability.Group)
}

//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string)

// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints := modelSupportEndpointsStr[ability.Model]
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
@@ -237,13 +223,12 @@ func updatePricing() {
modelSupportEndpointsStr[ability.Model] = endpoints
}

// 再补充模型自定义端点:若配置有效则替换默认端点,不做合并
for modelName, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
endpoints := make([]string, 0, len(raw))
for k, v := range raw {
switch v.(type) {
@@ -263,15 +248,12 @@ func updatePricing() {
for model, endpoints := range modelSupportEndpointsStr {
supportedEndpoints := make([]constant.EndpointType, 0)
for _, endpointStr := range endpoints {
endpointType := constant.EndpointType(endpointStr)
supportedEndpoints = append(supportedEndpoints, endpointType)
supportedEndpoints = append(supportedEndpoints, constant.EndpointType(endpointStr))
}
modelSupportEndpointTypes[model] = supportedEndpoints
}

// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
supportedEndpointMap = make(map[string]common.EndpointInfo)
// 1. 默认端点
for _, endpoints := range modelSupportEndpointTypes {
for _, et := range endpoints {
if info, ok := common.GetDefaultEndpointInfo(et); ok {
@@ -281,13 +263,12 @@ func updatePricing() {
}
}
}
// 2. 自定义端点(models 表)覆盖默认
for _, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
for k, v := range raw {
switch val := v.(type) {
case string:
@@ -301,42 +282,19 @@ func updatePricing() {
ep.Method = strings.ToUpper(m)
}
supportedEndpointMap[k] = ep
default:
// ignore unsupported types
}
}
}
}

// 从渠道定价表加载实际定价数据(仅启用渠道),同时获取渠道名称
var allCPs []struct {
ChannelPricing
ChannelName string
ChannelPublicName string
}
DB.Table("channel_pricings").
Select("channel_pricings.*, channels.name as channel_name, channels.public_name as channel_public_name").
Joins("JOIN channels ON channel_pricings.channel_id = channels.id").
Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL").
Find(&allCPs)
cpMap := make(map[string][]ChannelPricing)
channelNameMap := make(map[int]string)
for i := range allCPs {
cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i].ChannelPricing)
channelNameMap[allCPs[i].ChannelId] = ChannelDisplayName(allCPs[i].ChannelPublicName, allCPs[i].ChannelName)
}

pricingMap = make([]Pricing, 0)
pricingMap = make([]Pricing, 0, len(modelGroupsMap))
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model],
}

// 补充模型元数据(描述、标签、供应商、状态)
if meta, ok := metaMap[model]; ok {
// 若模型被禁用(status==0),则直接跳过,不返回给前端
if meta.Status == 0 {
continue
}
@@ -346,14 +304,10 @@ func updatePricing() {
pricing.VendorID = meta.VendorID
pricing.Type = meta.Type
}

// 使用渠道定价表中的实际数据,选取最便宜的渠道
applyBestChannelPricing(&pricing, cpMap[model], model)

applyGlobalDefault(&pricing, model)
pricingMap = append(pricingMap, pricing)
}

// 按 sort_order 排序 pricingMap,999999 视为未设置
sort.Slice(pricingMap, func(i, j int) bool {
mi, okI := metaMap[pricingMap[i].ModelName]
mj, okJ := metaMap[pricingMap[j].ModelName]
@@ -371,12 +325,10 @@ func updatePricing() {
return pricingMap[i].ModelName < pricingMap[j].ModelName
})

// 防止大更新后数据不通用
if len(pricingMap) > 0 {
pricingMap[0].PricingVersion = "82c4a357505fff6fee8462c3f7ec8a645bb95532669cb73b2cabee6a416ec24f"
}

// 刷新缓存映射,供高并发快速查询
modelEnableGroupsLock.Lock()
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
@@ -393,79 +345,24 @@ func updatePricing() {
lastGetPricingTime = time.Now()
}

// GetSupportedEndpointMap 返回全局端点到路径的映射
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
return supportedEndpointMap
}

// applyGlobalDefault 用全局默认值填充 Pricing(无渠道定价时的回退)
func applyGlobalDefault(pricing *Pricing, model string) {
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
pricing.ModelRatio = 0
pricing.CompletionRatio = 0
pricing.QuotaType = QuotaTypeByCall
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelPrice = 0
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
pricing.QuotaType = QuotaTypeByTokens
}
pricing.CacheRatio, _ = ratio_setting.GetCacheRatio(model)
pricing.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(model)
}

// applyBestChannelPricing 从渠道定价中选取最优(最便宜)的价格填充 Pricing
// 优先选按量计费(quota_type=0)的渠道,因为缓存价格仅对按量计费有意义
// 扩展比率字段(cache_ratio 等)为 0 表示未设置,需回退到全局默认值
func applyBestChannelPricing(pricing *Pricing, cps []ChannelPricing, model string) {
if len(cps) == 0 {
applyGlobalDefault(pricing, model)
return
}

// 优先选按量计费 (quota_type=0) 中 model_ratio 最低的渠道
var bestPerToken *ChannelPricing
for i := range cps {
cp := &cps[i]
if cp.QuotaType == 0 {
if bestPerToken == nil || cp.ModelRatio < bestPerToken.ModelRatio {
bestPerToken = cp
}
}
}

if bestPerToken != nil {
pricing.QuotaType = 0
pricing.ModelRatio = bestPerToken.ModelRatio
pricing.CompletionRatio = bestPerToken.CompletionRatio
if bestPerToken.CacheRatio > 0 {
pricing.CacheRatio = bestPerToken.CacheRatio
} else {
pricing.CacheRatio, _ = ratio_setting.GetCacheRatio(model)
}
if bestPerToken.CacheCreationRatio > 0 {
pricing.CacheCreationRatio = bestPerToken.CacheCreationRatio
} else {
pricing.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(model)
}
return
}

// 没有按量渠道,选按次计费 (quota_type=1) 中 model_price 最低的渠道
var bestPerCall *ChannelPricing
for i := range cps {
cp := &cps[i]
if cp.QuotaType == 1 {
if bestPerCall == nil || cp.ModelPrice < bestPerCall.ModelPrice {
bestPerCall = cp
}
}
}
if bestPerCall != nil {
pricing.QuotaType = 1
pricing.ModelPrice = bestPerCall.ModelPrice
return
}

applyGlobalDefault(pricing, model)
}

+ 0
- 60
model/pricing_tag.go Dosyayı Görüntüle

@@ -1,60 +0,0 @@
package model

import (
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)

// PricingTag 定价标签表
// 用于对渠道定价进行分类和标识
type PricingTag struct {
Id int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:64;not null;uniqueIndex"`
Color string `json:"color" gorm:"size:16;default:'#1890ff'"`
Description string `json:"description" gorm:"type:text"`
SortOrder int `json:"sort_order" gorm:"default:0"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}

func (pt *PricingTag) Insert() error {
pt.CreatedTime = common.GetTimestamp()
return DB.Create(pt).Error
}

func (pt *PricingTag) Update() error {
return DB.Model(&PricingTag{}).Where("id = ?", pt.Id).Updates(map[string]interface{}{
"name": pt.Name,
"color": pt.Color,
"description": pt.Description,
"sort_order": pt.SortOrder,
}).Error
}

func (pt *PricingTag) Delete() error {
return DB.Delete(pt).Error
}

func GetPricingTagById(id int) (*PricingTag, error) {
var pt PricingTag
err := DB.First(&pt, id).Error
if err != nil {
return nil, err
}
return &pt, nil
}

func GetAllPricingTags() ([]*PricingTag, error) {
var list []*PricingTag
err := DB.Order("sort_order ASC, id ASC").Find(&list).Error
return list, err
}

func GetPricingTagByName(name string) (*PricingTag, error) {
var pt PricingTag
err := DB.Where("name = ?", name).First(&pt).Error
if err != nil {
return nil, err
}
return &pt, nil
}

+ 96
- 6
model/pricing_test.go Dosyayı Görüntüle

@@ -2,13 +2,19 @@ package model

import (
"testing"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

const testPricingModel = "test-pricing-model-apply"
const testPricingApplyModel = "test-pricing-model-apply"
const testPricingCallModel = "test-pricing-call-model"
const testPricingGlobalModel = "test-pricing-global-model"

func resetPricingRatios(t *testing.T) {
t.Helper()
@@ -19,15 +25,61 @@ func resetPricingRatios(t *testing.T) {
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`))
}

func setupPricingTest(t *testing.T) {
t.Helper()

resetPricingRatios(t)

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)

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

origDB := DB
origUsingSQLite := common.UsingSQLite
origPricingMap := pricingMap
origPricingByModel := pricingByModel
origVendorsList := vendorsList
origSupportedEndpointMap := supportedEndpointMap
origModelSupportEndpointTypes := modelSupportEndpointTypes
origLastGetPricingTime := lastGetPricingTime

DB = db
common.UsingSQLite = true
pricingMap = nil
pricingByModel = nil
vendorsList = nil
supportedEndpointMap = nil
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
lastGetPricingTime = time.Time{}

require.NoError(t, db.AutoMigrate(&Model{}, &Vendor{}, &Channel{}, &Ability{}))

t.Cleanup(func() {
DB = origDB
common.UsingSQLite = origUsingSQLite
pricingMap = origPricingMap
pricingByModel = origPricingByModel
vendorsList = origVendorsList
supportedEndpointMap = origSupportedEndpointMap
modelSupportEndpointTypes = origModelSupportEndpointTypes
lastGetPricingTime = origLastGetPricingTime
require.NoError(t, sqlDB.Close())
resetPricingRatios(t)
})
}

func TestApplyGlobalDefault_PerTokenModel(t *testing.T) {
resetPricingRatios(t)
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testPricingModel+`":10}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testPricingModel+`":3}`))
require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testPricingModel+`":0.5}`))
require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testPricingModel+`":0.75}`))
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testPricingApplyModel+`":10}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testPricingApplyModel+`":3}`))
require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testPricingApplyModel+`":0.5}`))
require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testPricingApplyModel+`":0.75}`))

p := &Pricing{}
applyGlobalDefault(p, testPricingModel)
applyGlobalDefault(p, testPricingApplyModel)

require.Equal(t, QuotaTypeByTokens, p.QuotaType)
require.Equal(t, 10.0, p.ModelRatio)
@@ -47,3 +99,41 @@ func TestApplyGlobalDefault_PerCallModel(t *testing.T) {
require.Equal(t, 0.3, p.ModelPrice)
require.Equal(t, 0.0, p.ModelRatio)
}

func TestGetPricingByModel_UsesGlobalDefaultOnly(t *testing.T) {
setupPricingTest(t)

require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testPricingGlobalModel+`":10}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testPricingGlobalModel+`":3}`))
require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testPricingGlobalModel+`":0.5}`))
require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testPricingGlobalModel+`":0.75}`))

require.NoError(t, (&Model{
ModelName: testPricingGlobalModel,
Status: 1,
}).Insert())
require.NoError(t, (&Vendor{
Id: 1,
Name: "test-vendor",
SortOrder: 1,
}).Insert())
require.NoError(t, (&Channel{
Id: 1,
Type: constant.ChannelTypeOpenAI,
Key: "k",
Name: "channel-a",
Status: common.ChannelStatusEnabled,
Models: testPricingGlobalModel,
Group: "default",
}).Insert())

RefreshPricing()
pricing := GetPricingByModel(testPricingGlobalModel)

require.NotNil(t, pricing)
require.Equal(t, QuotaTypeByTokens, pricing.QuotaType)
require.Equal(t, 10.0, pricing.ModelRatio)
require.Equal(t, 3.0, pricing.CompletionRatio)
require.Equal(t, 0.5, pricing.CacheRatio)
require.Equal(t, 0.75, pricing.CacheCreationRatio)
}

+ 45
- 0
model/remove_channel_pricing_migration_test.go Dosyayı Görüntüle

@@ -0,0 +1,45 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

type channelPricingMigrationStub struct {
ID uint `gorm:"primaryKey"`
}

func (channelPricingMigrationStub) TableName() string {
return "channel_pricings"
}

type pricingTagMigrationStub struct {
ID uint `gorm:"primaryKey"`
}

func (pricingTagMigrationStub) TableName() string {
return "pricing_tags"
}

func TestMigrateDBDropsChannelPricingTables(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)

origDB := DB
origSQLite := common.UsingSQLite
DB = db
common.UsingSQLite = true
t.Cleanup(func() {
DB = origDB
common.UsingSQLite = origSQLite
})

require.NoError(t, db.AutoMigrate(&channelPricingMigrationStub{}, &pricingTagMigrationStub{}))
require.NoError(t, migrateDB())
require.False(t, DB.Migrator().HasTable("channel_pricings"))
require.False(t, DB.Migrator().HasTable("pricing_tags"))
}

+ 11
- 0
web/dist/index.html Dosyayı Görüntüle

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>new-api</title>
</head>
<body>
<div id="root">Frontend assets are not built in this checkout.</div>
</body>
</html>

Yükleniyor…
İptal
Kaydet