Browse Source

merge: feat/channel-pricing-extended → master

master
fengsilin 3 weeks ago
parent
commit
355757404b
8 changed files with 541 additions and 153 deletions
  1. +92
    -38
      controller/channel_pricing.go
  2. +82
    -80
      model/channel_pricing.go
  3. +121
    -0
      model/channel_pricing_test.go
  4. +5
    -1
      model/main.go
  5. +83
    -32
      relay/helper/price.go
  6. +122
    -0
      test-scripts/test_channel_pricing_extended.sh
  7. +23
    -1
      web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
  8. +13
    -1
      web/src/pages/Setting/Ratio/ChannelPricingView.jsx

+ 92
- 38
controller/channel_pricing.go View File

@@ -1,6 +1,7 @@
package controller

import (
"fmt"
"strconv"
"strings"

@@ -50,14 +51,19 @@ func GetChannelPricingByModel(c *gin.Context) {

// 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"`
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"`
}

// CreateChannelPricing 创建或更新渠道定价
@@ -68,6 +74,11 @@ func CreateChannelPricing(c *gin.Context) {
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 {
@@ -77,29 +88,43 @@ func CreateChannelPricing(c *gin.Context) {
existing.CompletionRatio = req.CompletionRatio
existing.ModelPrice = req.ModelPrice
existing.TagIds = req.TagIds
existing.CacheRatio = req.CacheRatio
existing.CacheCreationRatio = req.CacheCreationRatio
existing.ImageRatio = req.ImageRatio
existing.AudioRatio = req.AudioRatio
existing.AudioCompletionRatio = req.AudioCompletionRatio
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,
QuotaType: req.QuotaType,
ModelRatio: req.ModelRatio,
CompletionRatio: req.CompletionRatio,
ModelPrice: req.ModelPrice,
TagIds: req.TagIds,
ModelName: req.ModelName,
ChannelId: req.ChannelId,
QuotaType: req.QuotaType,
ModelRatio: req.ModelRatio,
CompletionRatio: req.CompletionRatio,
ModelPrice: req.ModelPrice,
TagIds: req.TagIds,
CacheRatio: req.CacheRatio,
CacheCreationRatio: req.CacheCreationRatio,
ImageRatio: req.ImageRatio,
AudioRatio: req.AudioRatio,
AudioCompletionRatio: req.AudioCompletionRatio,
}
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)
}

@@ -119,13 +144,18 @@ func BatchCreateChannelPricing(c *gin.Context) {
pricings := make([]*model.ChannelPricing, 0, len(req.Items))
for _, item := range req.Items {
pricings = append(pricings, &model.ChannelPricing{
ModelName: item.ModelName,
ChannelId: item.ChannelId,
QuotaType: item.QuotaType,
ModelRatio: item.ModelRatio,
CompletionRatio: item.CompletionRatio,
ModelPrice: item.ModelPrice,
TagIds: item.TagIds,
ModelName: item.ModelName,
ChannelId: item.ChannelId,
QuotaType: item.QuotaType,
ModelRatio: item.ModelRatio,
CompletionRatio: item.CompletionRatio,
ModelPrice: item.ModelPrice,
TagIds: item.TagIds,
CacheRatio: item.CacheRatio,
CacheCreationRatio: item.CacheCreationRatio,
ImageRatio: item.ImageRatio,
AudioRatio: item.AudioRatio,
AudioCompletionRatio: item.AudioCompletionRatio,
})
}

@@ -138,6 +168,11 @@ func BatchCreateChannelPricing(c *gin.Context) {
existing.CompletionRatio = cp.CompletionRatio
existing.ModelPrice = cp.ModelPrice
existing.TagIds = cp.TagIds
existing.CacheRatio = cp.CacheRatio
existing.CacheCreationRatio = cp.CacheCreationRatio
existing.ImageRatio = cp.ImageRatio
existing.AudioRatio = cp.AudioRatio
existing.AudioCompletionRatio = cp.AudioCompletionRatio
if err := existing.Update(); err != nil {
common.ApiError(c, err)
return
@@ -168,6 +203,7 @@ func DeleteChannelPricing(c *gin.Context) {
return
}

common.SysLog(fmt.Sprintf("[ChannelPricing] deleted: id=%d", id))
common.ApiSuccess(c, nil)
}

@@ -204,6 +240,22 @@ func CopyGlobalPricing(c *gin.Context) {
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 := ratio_setting.GetAudioRatio(ability.Model)
globalAudioCompletionRatio := ratio_setting.GetAudioCompletionRatio(ability.Model)

// 确定定价类型
var quotaType int
var ratio, completionRatio, price float64
@@ -228,17 +280,27 @@ func CopyGlobalPricing(c *gin.Context) {
existing.ModelRatio = ratio
existing.CompletionRatio = completionRatio
existing.ModelPrice = price
existing.CacheRatio = globalCacheRatio
existing.CacheCreationRatio = globalCacheCreationRatio
existing.ImageRatio = globalImageRatio
existing.AudioRatio = globalAudioRatio
existing.AudioCompletionRatio = globalAudioCompletionRatio
if err := existing.Update(); err == nil {
imported++
}
} else {
cp := &model.ChannelPricing{
ModelName: ability.Model,
ChannelId: channelId,
QuotaType: quotaType,
ModelRatio: ratio,
CompletionRatio: completionRatio,
ModelPrice: price,
ModelName: ability.Model,
ChannelId: channelId,
QuotaType: quotaType,
ModelRatio: ratio,
CompletionRatio: completionRatio,
ModelPrice: price,
CacheRatio: globalCacheRatio,
CacheCreationRatio: globalCacheCreationRatio,
ImageRatio: globalImageRatio,
AudioRatio: globalAudioRatio,
AudioCompletionRatio: globalAudioCompletionRatio,
}
if err := cp.Insert(); err == nil {
imported++
@@ -246,6 +308,7 @@ func CopyGlobalPricing(c *gin.Context) {
}
}

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,
@@ -302,16 +365,7 @@ func GetChannelPricingWithTags(c *gin.Context) {
for _, cp := range list {
item := &ChannelPricingWithTags{
ChannelPricing: cp,
Tags: make([]*model.PricingTag, 0),
}
if cp.TagIds != "" {
for _, idStr := range strings.Split(cp.TagIds, ",") {
if id, err := strconv.Atoi(idStr); err == nil {
if tag, ok := tagMap[id]; ok {
item.Tags = append(item.Tags, tag)
}
}
}
Tags: model.ParseTagIds(cp.TagIds, tagMap),
}
result = append(result, item)
}


+ 82
- 80
model/channel_pricing.go View File

@@ -5,7 +5,6 @@ import (
"strconv"
"strings"
"sync"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -17,8 +16,6 @@ import (
var (
channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId"
channelPricingCacheLock sync.RWMutex
channelPricingCacheTime time.Time
channelPricingCacheTTL = time.Minute * 5 // 缓存5分钟
)

// QuotaType 计费类型
@@ -41,6 +38,12 @@ type ChannelPricing struct {
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"`
}

func (cp *ChannelPricing) Insert() error {
@@ -49,31 +52,41 @@ func (cp *ChannelPricing) Insert() error {
cp.UpdatedTime = now
err := DB.Create(cp).Error
if err == nil {
InvalidateChannelPricingCache()
key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId)
channelPricingCacheLock.Lock()
channelPricingCache[key] = cp
channelPricingCacheLock.Unlock()
}
return err
}

func (cp *ChannelPricing) Update() error {
cp.UpdatedTime = common.GetTimestamp()
err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id).Updates(map[string]interface{}{
"quota_type": cp.QuotaType,
"model_ratio": cp.ModelRatio,
"completion_ratio": cp.CompletionRatio,
"model_price": cp.ModelPrice,
"tag_ids": cp.TagIds,
"updated_time": cp.UpdatedTime,
}).Error
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", "updated_time").
Updates(cp).Error
if err == nil {
InvalidateChannelPricingCache()
key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId)
channelPricingCacheLock.Lock()
channelPricingCache[key] = cp
channelPricingCacheLock.Unlock()
}
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 {
InvalidateChannelPricingCache()
key := getChannelPricingCacheKey(existing.ModelName, existing.ChannelId)
channelPricingCacheLock.Lock()
delete(channelPricingCache, key)
channelPricingCacheLock.Unlock()
}
return err
}
@@ -132,6 +145,11 @@ func BatchUpsertChannelPricing(pricings []*ChannelPricing) error {
"completion_ratio",
"model_price",
"tag_ids",
"cache_ratio",
"cache_creation_ratio",
"image_ratio",
"audio_ratio",
"audio_completion_ratio",
"updated_time",
}),
}).Create(&pricings).Error
@@ -142,71 +160,49 @@ func getChannelPricingCacheKey(modelName string, channelId int) string {
return fmt.Sprintf("%s:%d", modelName, channelId)
}

// GetEffectivePricing 获取有效定价(优先渠道定价,回退全局定价)
// 返回: modelRatio, completionRatio, modelPrice, usePrice, found
func GetEffectivePricing(modelName string, channelId int) (modelRatio, completionRatio, modelPrice float64, usePrice, found bool) {
cacheKey := getChannelPricingCacheKey(modelName, channelId)

// 首先检查缓存
// GetEffectivePricing 获取有效定价(纯内存查找)
func GetEffectivePricing(modelName string, channelId int) (*ChannelPricing, bool) {
key := getChannelPricingCacheKey(modelName, channelId)
channelPricingCacheLock.RLock()
// 检查缓存是否过期
if time.Since(channelPricingCacheTime) < channelPricingCacheTTL {
if cp, ok := channelPricingCache[cacheKey]; ok {
channelPricingCacheLock.RUnlock()
return cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.QuotaType == QuotaTypeByCall, true
}
}
cp, ok := channelPricingCache[key]
channelPricingCacheLock.RUnlock()

// 缓存未命中或已过期,查询数据库
var cp ChannelPricing
err := DB.Where("model_name = ? AND channel_id = ?", modelName, channelId).First(&cp).Error
if err != nil {
// 未找到渠道定价,返回 false 让调用者使用全局定价
return 0, 0, 0, false, false
if !ok {
return nil, false
}
return cp, true
}

// 更新缓存
channelPricingCacheLock.Lock()
if channelPricingCacheTime.IsZero() || time.Since(channelPricingCacheTime) >= channelPricingCacheTTL {
// 缓存过期,清空并更新时间
channelPricingCache = make(map[string]*ChannelPricing)
channelPricingCacheTime = time.Now()
// ParseTagIds 解析逗号分隔的标签ID字符串为 PricingTag 切片
func ParseTagIds(tagIds string, tagMap map[int]*PricingTag) []*PricingTag {
if tagIds == "" {
return nil
}
channelPricingCache[cacheKey] = &cp
channelPricingCacheLock.Unlock()

return cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.QuotaType == QuotaTypeByCall, true
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
}

// RefreshChannelPricingCache 刷新渠道定价缓存
func RefreshChannelPricingCache() {
channelPricingCacheLock.Lock()
defer channelPricingCacheLock.Unlock()

// 清空缓存
channelPricingCache = make(map[string]*ChannelPricing)
channelPricingCacheTime = time.Now()

// 预加载所有渠道定价
// 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 {
cacheKey := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId)
channelPricingCache[cacheKey] = cp
key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId)
channelPricingCache[key] = cp
}
}

// InvalidateChannelPricingCache 使渠道定价缓存失效
func InvalidateChannelPricingCache() {
channelPricingCacheLock.Lock()
defer channelPricingCacheLock.Unlock()

channelPricingCache = make(map[string]*ChannelPricing)
channelPricingCacheTime = time.Time{} // 重置为零值
channelPricingCacheLock.Unlock()
common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings)))
}

// ChannelPricingWithChannel 带渠道信息的定价响应
@@ -221,7 +217,12 @@ type ChannelPricingWithChannel struct {
ModelRatio float64 `json:"model_ratio"`
CompletionRatio float64 `json:"completion_ratio"`
ModelPrice float64 `json:"model_price"`
HasCustomPricing bool `json:"has_custom_pricing"` // 是否有自定义定价
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"`
}

// GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息)
@@ -249,7 +250,12 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin
if !hasPrice {
globalModelPrice = 0
}

// 获取全局扩展比率(用于 CASE WHEN 回退)
globalCacheRatio, _ := ratio_setting.GetCacheRatio(modelName)
globalCacheCreationRatio, _ := ratio_setting.GetCreateCacheRatio(modelName)
globalImageRatio, _ := ratio_setting.GetImageRatio(modelName)
globalAudioRatio := ratio_setting.GetAudioRatio(modelName)
globalAudioCompletionRatio := ratio_setting.GetAudioCompletionRatio(modelName)
// 查询所有支持该模型的渠道,左连接渠道定价表
err := DB.Table("abilities").
Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type,
@@ -259,14 +265,19 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin
COALESCE(channel_pricings.model_price, ?) as model_price,
channel_pricings.id as id,
channel_pricings.tag_ids as tag_ids,
CASE WHEN channel_pricings.cache_ratio > 0 THEN channel_pricings.cache_ratio ELSE ? END as cache_ratio,
CASE WHEN channel_pricings.cache_creation_ratio > 0 THEN channel_pricings.cache_creation_ratio ELSE ? END as cache_creation_ratio,
CASE WHEN channel_pricings.image_ratio > 0 THEN channel_pricings.image_ratio ELSE ? END as image_ratio,
CASE WHEN channel_pricings.audio_ratio > 0 THEN channel_pricings.audio_ratio ELSE ? END as audio_ratio,
CASE WHEN channel_pricings.audio_completion_ratio > 0 THEN channel_pricings.audio_completion_ratio ELSE ? END as audio_completion_ratio,
(channel_pricings.id IS NOT NULL) as has_custom_pricing`,
defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice).
defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice, globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio).
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.type, channel_pricings.quota_type, channel_pricings.model_ratio, channel_pricings.completion_ratio, channel_pricings.model_price, channel_pricings.id, channel_pricings.tag_ids").
Group("abilities.channel_id, channels.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").
Scan(&results).Error
if err != nil {
return nil, err
@@ -286,16 +297,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin

// 为每个渠道定价填充标签
for _, result := range results {
if result.TagIds != "" {
result.Tags = make([]*PricingTag, 0)
for _, idStr := range strings.Split(result.TagIds, ",") {
if id, err := strconv.Atoi(strings.TrimSpace(idStr)); err == nil {
if tag, ok := tagMap[id]; ok {
result.Tags = append(result.Tags, tag)
}
}
}
}
result.Tags = ParseTagIds(result.TagIds, tagMap)
}

return results, nil


+ 121
- 0
model/channel_pricing_test.go View File

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

import (
"testing"

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

func setupChannelPricingDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := DB
DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&ChannelPricing{}))

t.Cleanup(func() {
DB = origDB
sqlDB.Close()
})
return db
}

func TestCacheWriteThrough_Insert(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{
ModelName: "test-insert-model",
ChannelId: 9001,
QuotaType: QuotaTypeByTokens,
ModelRatio: 1.0,
CacheRatio: 0.8,
ImageRatio: 1.2,
}
require.NoError(t, cp.Insert())
defer cp.Delete()

found, ok := GetEffectivePricing("test-insert-model", 9001)
assert.True(t, ok)
assert.Equal(t, 0.8, found.CacheRatio)
assert.Equal(t, 1.2, found.ImageRatio)
}

func TestCacheWriteThrough_Update(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{
ModelName: "test-update-model",
ChannelId: 9002,
QuotaType: QuotaTypeByTokens,
ModelRatio: 1.0,
}
require.NoError(t, cp.Insert())
defer cp.Delete()

cp.CacheRatio = 0.9
cp.AudioRatio = 1.5
require.NoError(t, cp.Update())

found, ok := GetEffectivePricing("test-update-model", 9002)
assert.True(t, ok)
assert.Equal(t, 0.9, found.CacheRatio)
assert.Equal(t, 1.5, found.AudioRatio)
}

func TestCacheWriteThrough_Delete(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{
ModelName: "test-delete-model",
ChannelId: 9003,
QuotaType: QuotaTypeByTokens,
ModelRatio: 1.0,
}
require.NoError(t, cp.Insert())

require.NoError(t, cp.Delete())

found, ok := GetEffectivePricing("test-delete-model", 9003)
assert.False(t, ok)
assert.Nil(t, found)
}

func TestExtendedFields_DefaultZero(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{
ModelName: "test-default-model",
ChannelId: 9004,
QuotaType: QuotaTypeByTokens,
ModelRatio: 1.0,
}
require.NoError(t, cp.Insert())
defer cp.Delete()

found, ok := GetEffectivePricing("test-default-model", 9004)
assert.True(t, ok)
assert.Equal(t, 0.0, found.CacheRatio)
assert.Equal(t, 0.0, found.CacheCreationRatio)
assert.Equal(t, 0.0, found.ImageRatio)
assert.Equal(t, 0.0, found.AudioRatio)
assert.Equal(t, 0.0, found.AudioCompletionRatio)
}

func TestGetEffectivePricing_NotFound(t *testing.T) {
setupChannelPricingDB(t)

found, ok := GetEffectivePricing("nonexistent-model-xyz", 99999)
assert.False(t, ok)
assert.Nil(t, found)
}

+ 5
- 1
model/main.go View File

@@ -203,7 +203,11 @@ func InitDB() (err error) {
}
common.SysLog("database migration started")
err = migrateDB()
return err
if err != nil {
return err
}
LoadChannelPricingCache()
return nil
} else {
common.FatalLog(err)
}


+ 83
- 32
relay/helper/price.go View File

@@ -52,17 +52,44 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var modelRatio float64
var completionRatio float64
var channelPricingFound bool
var cacheRatio float64
var imageRatio float64
var cacheCreationRatio float64
var cacheCreationRatio5m float64
var cacheCreationRatio1h float64
var audioRatio float64
var audioCompletionRatio float64

// 尝试获取渠道定价(优先于全局定价)
channelMetaAvailable := info != nil && info.ChannelMeta != nil && info.ChannelId > 0
if channelMetaAvailable {
cpRatio, cpCompletionRatio, cpPrice, cpUsePrice, found := model.GetEffectivePricing(info.OriginModelName, info.ChannelId)
cp, found := model.GetEffectivePricing(info.OriginModelName, info.ChannelId)
if found {
modelRatio = cpRatio
completionRatio = cpCompletionRatio
modelPrice = cpPrice
usePrice = cpUsePrice
modelRatio = cp.ModelRatio
completionRatio = cp.CompletionRatio
modelPrice = cp.ModelPrice
usePrice = cp.QuotaType == model.QuotaTypeByCall
channelPricingFound = true
if cp.CacheRatio != 0 {
cacheRatio = cp.CacheRatio
}
if cp.CacheCreationRatio != 0 {
cacheCreationRatio = cp.CacheCreationRatio
cacheCreationRatio5m = cp.CacheCreationRatio
cacheCreationRatio1h = cp.CacheCreationRatio * claudeCacheCreation1hMultiplier
}
if cp.ImageRatio != 0 {
imageRatio = cp.ImageRatio
}
if cp.AudioRatio != 0 {
audioRatio = cp.AudioRatio
}
if cp.AudioCompletionRatio != 0 {
audioCompletionRatio = cp.AudioCompletionRatio
}
if common.DebugEnabled {
println(fmt.Sprintf("[ChannelPricing] hit: model=%s channel=%d source=cache", info.OriginModelName, info.ChannelId))
}
}
}

@@ -74,13 +101,6 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
groupRatioInfo := HandleGroupRatio(c, info)

var preConsumedQuota int
var cacheRatio float64
var imageRatio float64
var cacheCreationRatio float64
var cacheCreationRatio5m float64
var cacheCreationRatio1h float64
var audioRatio float64
var audioCompletionRatio float64
var freeModel bool
if !usePrice {
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
@@ -103,14 +123,24 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
}
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
}
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
cacheCreationRatio5m = cacheCreationRatio
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
if cacheRatio == 0 {
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
}
if cacheCreationRatio == 0 {
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
cacheCreationRatio5m = cacheCreationRatio
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
}
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)
}
ratio := modelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
@@ -217,30 +247,51 @@ func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInf
return
}

cpRatio, cpCompletionRatio, cpPrice, cpUsePrice, found := model.GetEffectivePricing(info.OriginModelName, channelId)
cp, found := model.GetEffectivePricing(info.OriginModelName, channelId)
if !found {
return
}

// 更新 PriceData 中的定价相关字段
info.PriceData.ModelRatio = cpRatio
info.PriceData.CompletionRatio = cpCompletionRatio
info.PriceData.UsePrice = cpUsePrice
// 按次计费模式下使用渠道价格,按量计费模式下设置 ModelPrice = -1 让前端识别计费模式
if cpUsePrice {
info.PriceData.ModelPrice = cpPrice
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
}

// 重新计算预扣费额度(用于后续可能的引用)
if cpUsePrice {
info.PriceData.QuotaToPreConsume = int(cpPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio)
if cp.CacheRatio != 0 {
info.PriceData.CacheRatio = cp.CacheRatio
}
if cp.CacheCreationRatio != 0 {
info.PriceData.CacheCreationRatio = cp.CacheCreationRatio
info.PriceData.CacheCreation5mRatio = cp.CacheCreationRatio
info.PriceData.CacheCreation1hRatio = cp.CacheCreationRatio * claudeCacheCreation1hMultiplier
}
if cp.ImageRatio != 0 {
info.PriceData.ImageRatio = cp.ImageRatio
}
if cp.AudioRatio != 0 {
info.PriceData.AudioRatio = cp.AudioRatio
}
if cp.AudioCompletionRatio != 0 {
info.PriceData.AudioCompletionRatio = 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 := cpRatio * info.PriceData.GroupRatioInfo.GroupRatio
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))
}
}

+ 122
- 0
test-scripts/test_channel_pricing_extended.sh View File

@@ -0,0 +1,122 @@
#!/bin/bash
set -e

BASE_URL="${1:-http://localhost:3000}"
ADMIN_KEY="${2}"

if [ -z "$ADMIN_KEY" ]; then
echo "Usage: $0 <base_url> <admin_key>"
exit 1
fi

echo "=== 渠道定价增强 - 集成测试 ==="

# ---------- 1. 创建渠道定价(含扩展字段) ----------
echo "--- Test 1: Create with extended ratios ---"
RESP=$(curl -s -X POST "$BASE_URL/api/channel-pricing/" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"model_name": "claude-3-5-sonnet",
"channel_id": 1,
"quota_type": 0,
"model_ratio": 3.0,
"completion_ratio": 15.0,
"cache_ratio": 0.5,
"cache_creation_ratio": 0.625,
"image_ratio": 1.5,
"audio_ratio": 2.0,
"audio_completion_ratio": 1.8
}')
echo "$RESP" | python3 -m json.tool

CACHE_RATIO=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['cache_ratio'])")
if [ "$CACHE_RATIO" = "0.5" ]; then
echo " [PASS] cache_ratio = 0.5"
else
echo " [FAIL] cache_ratio expected 0.5, got $CACHE_RATIO"
fi

# ---------- 2. 查询渠道定价(验证新字段返回) ----------
echo "--- Test 2: Query and verify extended fields ---"
RESP=$(curl -s "$BASE_URL/api/channel-pricing/model/claude-3-5-sonnet" \
-H "Authorization: Bearer $ADMIN_KEY")
echo "$RESP" | python3 -m json.tool

# ---------- 3. 更新渠道定价(修改扩展字段) ----------
echo "--- Test 3: Update extended ratios ---"
RESP=$(curl -s -X POST "$BASE_URL/api/channel-pricing/" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"model_name": "claude-3-5-sonnet",
"channel_id": 1,
"quota_type": 0,
"model_ratio": 3.0,
"completion_ratio": 15.0,
"cache_ratio": 0.8,
"cache_creation_ratio": 0.0
}')
echo "$RESP" | python3 -m json.tool

# ---------- 4. 验证回退:未设置的字段为 0 ----------
echo "--- Test 4: Verify unset fields = 0 ---"
CACHE_CREATION=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['cache_creation_ratio'])")
if [ "$CACHE_CREATION" = "0.0" ]; then
echo " [PASS] cache_creation_ratio = 0 (unset)"
else
echo " [FAIL] cache_creation_ratio expected 0.0, got $CACHE_CREATION"
fi

# ---------- 5. 负值校验 ----------
echo "--- Test 5: Reject negative values ---"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/api/channel-pricing/" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"model_name": "claude-3-5-sonnet",
"channel_id": 1,
"quota_type": 0,
"model_ratio": 3.0,
"cache_ratio": -1.0
}')
if [ "$HTTP_CODE" = "400" ] || [ "$HTTP_CODE" = "422" ]; then
echo " [PASS] negative value rejected with HTTP $HTTP_CODE"
else
echo " [FAIL] expected 400/422, got HTTP $HTTP_CODE"
fi

# ---------- 6. 用户端查询(带渠道信息 + CASE WHEN 回退) ----------
echo "--- Test 6: User-facing query with global fallback ---"
RESP=$(curl -s "$BASE_URL/api/channel-pricing/model/claude-3-5-sonnet" \
-H "Authorization: Bearer $ADMIN_KEY")
echo "$RESP" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for item in data.get('data', []):
ch = item.get('channel_id', '?')
cr = item.get('cache_ratio', 'N/A')
ccr = item.get('cache_creation_ratio', 'N/A')
ir = item.get('image_ratio', 'N/A')
print(f' channel={ch} cache_ratio={cr} cache_creation_ratio={ccr} image_ratio={ir}')
"

# ---------- 7. 删除 + 验证缓存清除 ----------
echo "--- Test 7: Delete and verify cache cleared ---"
ID=$(curl -s "$BASE_URL/api/channel-pricing/model/claude-3-5-sonnet" \
-H "Authorization: Bearer $ADMIN_KEY" | \
python3 -c "import sys,json; items=json.load(sys.stdin).get('data',[]); print(items[0]['id'] if items else '')")
if [ -n "$ID" ]; then
curl -s -X DELETE "$BASE_URL/api/channel-pricing/$ID" \
-H "Authorization: Bearer $ADMIN_KEY"
echo " Deleted pricing id=$ID"

RESP=$(curl -s "$BASE_URL/api/channel-pricing/model/claude-3-5-sonnet" \
-H "Authorization: Bearer $ADMIN_KEY")
COUNT=$(echo "$RESP" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('data',[])))")
echo " Remaining pricings for claude-3-5-sonnet: $COUNT"
else
echo " [SKIP] No pricing to delete"
fi

echo "=== 集成测试完成 ==="

+ 23
- 1
web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx View File

@@ -112,6 +112,8 @@ const ChannelPricingCard = ({
completionRatio: item.completion_ratio,
modelPrice: item.model_price,
hasCustomPricing: item.has_custom_pricing,
cacheRatio: item.cache_ratio,
cacheCreationRatio: item.cache_creation_ratio,
}));

// 判断是否存在按次计费的渠道
@@ -205,6 +207,26 @@ const ChannelPricingCard = ({
]
: [];

// 判断是否存在高级比例数据
const hasAdvancedPricing = tableData.some(
(item) => item.cacheRatio > 0 || item.cacheCreationRatio > 0
);

const advancedColumns = hasAdvancedPricing
? [
{
title: t('缓存读取'),
dataIndex: 'cacheRatio',
render: (v) => (v > 0 ? v : '-'),
},
{
title: t('缓存创建'),
dataIndex: 'cacheCreationRatio',
render: (v) => (v > 0 ? v : '-'),
},
]
: [];

// 只有存在按次计费的渠道时才显示固定价格列
const callBasedColumn = hasCallBased
? [
@@ -226,7 +248,7 @@ const ChannelPricingCard = ({
]
: [];

const columns = [...baseColumns, ...tokenBasedColumns, ...callBasedColumn];
const columns = [...baseColumns, ...tokenBasedColumns, ...advancedColumns, ...callBasedColumn];

return (
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>


+ 13
- 1
web/src/pages/Setting/Ratio/ChannelPricingView.jsx View File

@@ -463,7 +463,6 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
rowKey="modelName"
expandedRowKeys={expandedRowKeys}
onExpandedRowsChange={(keys) => {
console.log('Expanded keys:', keys);
setExpandedRowKeys(keys);
}}
expandedRowRender={expandedRowRender}
@@ -490,6 +489,11 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
completion_ratio: editingRecord?.pricing?.completion_ratio ?? 0,
model_price: editingRecord?.pricing?.model_price ?? 0,
tag_ids: getInitialTagIds(editingRecord?.pricing),
cache_ratio: editingRecord?.pricing?.cache_ratio ?? 0,
cache_creation_ratio: editingRecord?.pricing?.cache_creation_ratio ?? 0,
image_ratio: editingRecord?.pricing?.image_ratio ?? 0,
audio_ratio: editingRecord?.pricing?.audio_ratio ?? 0,
audio_completion_ratio: editingRecord?.pricing?.audio_completion_ratio ?? 0,
}}
>
<Form.Input
@@ -576,6 +580,14 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
initValue={tokenPrices.outputTokenPrice}
/>
</div>

<Form.Section text={t('高级比例(留空使用全局默认值)')}>
<Form.InputNumber field="cache_ratio" label={t('缓存读取倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
<Form.InputNumber field="cache_creation_ratio" label={t('缓存创建倍率(5分钟)')} min={0} step={0.01} placeholder={t('全局默认值(1小时自动按 1.6x 计算)')} />
<Form.InputNumber field="image_ratio" label={t('图片倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
<Form.InputNumber field="audio_ratio" label={t('音频输入倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
<Form.InputNumber field="audio_completion_ratio" label={t('音频输出倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
</Form.Section>
</>
)}
{currentQuotaType === 1 && (


Loading…
Cancel
Save