| @@ -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) | |||
| } | |||
| @@ -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 | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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)) | |||
| } | |||
| } | |||
| @@ -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 "=== 集成测试完成 ===" | |||
| @@ -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'> | |||
| @@ -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 && ( | |||