Bläddra i källkod

feat(channel): 添加模型默认通道功能,支持优先路由和卡片标识

- 新增 is_default 字段和缓存层,支持管理员为模型指定默认通道
- Distribute 中间件优先级调整:Token 指定 → 默认通道 → 亲和性 → 随机
- 模型定价卡片和详情弹窗展示默认通道 amber 标识
- 管理后台定价页面新增星标切换默认通道
- 新增 set_default / clear_default API 和 6 个单元测试
- 简化卡片价格显示(移除内联缓存价格,改为详情弹窗展示)

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 2 veckor sedan
förälder
incheckning
3a29f3772f
13 ändrade filer med 451 tillägg och 71 borttagningar
  1. +48
    -0
      controller/channel_pricing.go
  2. +46
    -10
      middleware/distributor.go
  3. +127
    -2
      model/channel_pricing.go
  4. +121
    -0
      model/channel_pricing_test.go
  5. +16
    -4
      model/pricing.go
  6. +2
    -0
      router/api-router.go
  7. +12
    -6
      web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
  8. +13
    -1
      web/src/components/table/model-pricing/view/card/PricingCardView.jsx
  9. +8
    -0
      web/src/helpers/api.js
  10. +6
    -46
      web/src/helpers/utils.jsx
  11. +7
    -1
      web/src/i18n/locales/en.json
  12. +7
    -1
      web/src/i18n/locales/zh-CN.json
  13. +38
    -0
      web/src/pages/Setting/Ratio/ChannelPricingView.jsx

+ 48
- 0
controller/channel_pricing.go Visa fil

@@ -340,3 +340,51 @@ func GetChannelPricingWithTags(c *gin.Context) {
"items": result, "items": result,
}) })
} }

// SetDefaultChannelRequest 设置默认通道请求
type SetDefaultChannelRequest struct {
ModelName string `json:"model_name" binding:"required"`
ChannelId int `json:"channel_id" binding:"required"`
}

// SetDefaultChannel 设置指定模型的默认通道
func SetDefaultChannel(c *gin.Context) {
var req SetDefaultChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}

// 验证渠道定价记录存在
existing, err := model.GetChannelPricing(req.ModelName, req.ChannelId)
if err != nil || existing == nil {
common.ApiErrorMsg(c, "channel pricing not found for this model and channel")
return
}

if err := model.SetDefaultChannel(req.ModelName, req.ChannelId); err != nil {
common.ApiError(c, err)
return
}

common.SysLog(fmt.Sprintf("[ChannelPricing] set default: model=%s channel=%d", req.ModelName, req.ChannelId))
common.ApiSuccess(c, nil)
}

// ClearDefaultChannel 清除指定模型的默认通道
func ClearDefaultChannel(c *gin.Context) {
modelName := c.Param("name")
modelName = strings.TrimPrefix(modelName, "/")
if modelName == "" {
common.ApiErrorMsg(c, "model name is required")
return
}

if err := model.ClearDefaultChannel(modelName); err != nil {
common.ApiError(c, err)
return
}

common.SysLog(fmt.Sprintf("[ChannelPricing] cleared default: model=%s", modelName))
common.ApiSuccess(c, nil)
}

+ 46
- 10
middleware/distributor.go Visa fil

@@ -107,25 +107,61 @@ func Distribute() func(c *gin.Context) {
} }
} }


if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
// 默认通道检查(管理员配置的模型默认通道)
if channel == nil {
if defaultChannelId, ok := model.GetDefaultChannelId(modelRequest.Model); ok {
defaultCh, err := model.CacheGetChannel(defaultChannelId)
if err != nil || defaultCh == nil {
common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not found in cache, fallback", modelRequest.Model, defaultChannelId))
} else if defaultCh.Status != common.ChannelStatusEnabled {
common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d disabled(status=%d), fallback", modelRequest.Model, defaultChannelId, defaultCh.Status))
} else if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup) autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups { for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, defaultCh.Id) {
channel = defaultCh
selectGroup = g selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g) common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
service.MarkChannelAffinityUsed(c, g, preferred.Id)
common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (auto group=%s)", modelRequest.Model, defaultChannelId, g))
break break
} }
} }
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
if channel == nil {
common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for any auto group, fallback", modelRequest.Model, defaultChannelId))
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, defaultCh.Id) {
channel = defaultCh
selectGroup = usingGroup selectGroup = usingGroup
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (group=%s)", modelRequest.Model, defaultChannelId, usingGroup))
} else {
common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for group=%s, fallback", modelRequest.Model, defaultChannelId, usingGroup))
}
}
}

// 通道亲和性检查(仅在未选中默认通道时生效)
if channel == nil {
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
service.MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
selectGroup = usingGroup
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
} }
} }
} }


+ 127
- 2
model/channel_pricing.go Visa fil

@@ -16,6 +16,10 @@ import (
var ( var (
channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId" channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId"
channelPricingCacheLock sync.RWMutex channelPricingCacheLock sync.RWMutex

// 默认通道缓存:modelName → channelId
defaultChannelCache = make(map[string]int)
defaultChannelCacheLock sync.RWMutex
) )


// QuotaType 计费类型 // QuotaType 计费类型
@@ -44,6 +48,7 @@ type ChannelPricing struct {
ImageRatio float64 `json:"image_ratio" gorm:"default:0"` ImageRatio float64 `json:"image_ratio" gorm:"default:0"`
AudioRatio float64 `json:"audio_ratio" gorm:"default:0"` AudioRatio float64 `json:"audio_ratio" gorm:"default:0"`
AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"` AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"`
IsDefault bool `json:"is_default" gorm:"default:false;index"`
} }


// setCache 写穿透缓存 // setCache 写穿透缓存
@@ -80,6 +85,9 @@ func (cp *ChannelPricing) Insert() error {
err := DB.Create(cp).Error err := DB.Create(cp).Error
if err == nil { if err == nil {
setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp)
if cp.IsDefault {
setDefaultChannelCache(cp.ModelName, cp.ChannelId)
}
} }
return err return err
} }
@@ -89,10 +97,15 @@ func (cp *ChannelPricing) Update() error {
err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id). err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id).
Select("quota_type", "model_ratio", "completion_ratio", "model_price", Select("quota_type", "model_ratio", "completion_ratio", "model_price",
"tag_ids", "cache_ratio", "cache_creation_ratio", "image_ratio", "tag_ids", "cache_ratio", "cache_creation_ratio", "image_ratio",
"audio_ratio", "audio_completion_ratio", "updated_time").
"audio_ratio", "audio_completion_ratio", "is_default", "updated_time").
Updates(cp).Error Updates(cp).Error
if err == nil { if err == nil {
setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp)
if cp.IsDefault {
setDefaultChannelCache(cp.ModelName, cp.ChannelId)
} else {
clearDefaultChannelCacheIfMatch(cp.ModelName, cp.Id)
}
} }
return err return err
} }
@@ -105,6 +118,9 @@ func (cp *ChannelPricing) Delete() error {
err := DB.Delete(cp).Error err := DB.Delete(cp).Error
if err == nil { if err == nil {
removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId)) removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId))
if existing.IsDefault {
clearDefaultChannelCache(existing.ModelName)
}
} }
return err return err
} }
@@ -226,6 +242,8 @@ func LoadChannelPricingCache() {
channelPricingCache[key] = cp channelPricingCache[key] = cp
} }
channelPricingCacheLock.Unlock() channelPricingCacheLock.Unlock()

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


@@ -247,6 +265,7 @@ type ChannelPricingWithChannel struct {
ImageRatio float64 `json:"image_ratio"` ImageRatio float64 `json:"image_ratio"`
AudioRatio float64 `json:"audio_ratio"` AudioRatio float64 `json:"audio_ratio"`
AudioCompletionRatio float64 `json:"audio_completion_ratio"` AudioCompletionRatio float64 `json:"audio_completion_ratio"`
IsDefault bool `json:"is_default"`
} }


// GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息) // GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息)
@@ -289,6 +308,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin
COALESCE(channel_pricings.image_ratio, 0) as image_ratio, COALESCE(channel_pricings.image_ratio, 0) as image_ratio,
COALESCE(channel_pricings.audio_ratio, 0) as audio_ratio, COALESCE(channel_pricings.audio_ratio, 0) as audio_ratio,
COALESCE(channel_pricings.audio_completion_ratio, 0) as audio_completion_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`, (channel_pricings.id IS NOT NULL) as has_custom_pricing`,
defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice). defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice).
Joins("LEFT JOIN channels ON abilities.channel_id = channels.id"). Joins("LEFT JOIN channels ON abilities.channel_id = channels.id").
@@ -296,7 +316,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin
Where("abilities.model = ?", modelName). Where("abilities.model = ?", modelName).
Where("abilities.enabled = ?", true). Where("abilities.enabled = ?", true).
Where("channels.status = ?", 1). // 只显示启用的渠道 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, channel_pricings.cache_ratio, channel_pricings.cache_creation_ratio, channel_pricings.image_ratio, channel_pricings.audio_ratio, channel_pricings.audio_completion_ratio").
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, channel_pricings.is_default").
Scan(&results).Error Scan(&results).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -321,3 +341,108 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin


return results, nil return results, nil
} }

// === 默认通道缓存 ===

// syncIsDefaultToCache 将默认标记的变更同步到 channelPricingCache
func syncIsDefaultToCache(modelName string, channelId int, isDefault bool) {
channelPricingCacheLock.Lock()
for key, cp := range channelPricingCache {
if cp.ModelName == modelName {
if isDefault {
cp.IsDefault = cp.ChannelId == channelId
} else {
cp.IsDefault = false
}
}
channelPricingCache[key] = cp
}
channelPricingCacheLock.Unlock()
}

// setDefaultChannelCache 设置默认通道缓存
func setDefaultChannelCache(modelName string, channelId int) {
defaultChannelCacheLock.Lock()
defaultChannelCache[modelName] = channelId
defaultChannelCacheLock.Unlock()
}

// clearDefaultChannelCache 清除指定模型的默认通道缓存
func clearDefaultChannelCache(modelName string) {
defaultChannelCacheLock.Lock()
delete(defaultChannelCache, modelName)
defaultChannelCacheLock.Unlock()
}

// clearDefaultChannelCacheIfMatch 如果默认通道的定价记录 ID 匹配则清除
func clearDefaultChannelCacheIfMatch(modelName string, pricingId int) {
defaultChannelCacheLock.RLock()
cachedId, ok := defaultChannelCache[modelName]
defaultChannelCacheLock.RUnlock()
if !ok {
return
}
// 需要通过缓存找到对应的 pricing 来比对
key := getChannelPricingCacheKey(modelName, cachedId)
channelPricingCacheLock.RLock()
cp, exists := channelPricingCache[key]
channelPricingCacheLock.RUnlock()
if exists && cp.Id == pricingId {
clearDefaultChannelCache(modelName)
}
}

// rebuildDefaultChannelCache 从全量数据构建默认通道缓存(启动时调用)
func rebuildDefaultChannelCache(pricings []*ChannelPricing) {
defaultChannelCacheLock.Lock()
defaultChannelCache = make(map[string]int)
for _, cp := range pricings {
if cp.IsDefault {
defaultChannelCache[cp.ModelName] = cp.ChannelId
}
}
defaultChannelCacheLock.Unlock()
common.SysLog(fmt.Sprintf("[ChannelPricing] default channel cache loaded %d records", len(defaultChannelCache)))
}

// GetDefaultChannelId 获取指定模型的默认通道 ID(纯内存读)
func GetDefaultChannelId(modelName string) (int, bool) {
defaultChannelCacheLock.RLock()
id, ok := defaultChannelCache[modelName]
defaultChannelCacheLock.RUnlock()
return id, ok
}

// SetDefaultChannel 设置指定模型的默认通道(事务保证互斥)
func SetDefaultChannel(modelName string, channelId int) error {
return DB.Transaction(func(tx *gorm.DB) error {
// 清除该模型所有现有的默认标记
if err := tx.Model(&ChannelPricing{}).
Where("model_name = ? AND is_default = ?", modelName, true).
Update("is_default", false).Error; err != nil {
return err
}
// 设置新的默认
if err := tx.Model(&ChannelPricing{}).
Where("model_name = ? AND channel_id = ?", modelName, channelId).
Update("is_default", true).Error; err != nil {
return err
}
// 更新缓存
setDefaultChannelCache(modelName, channelId)
syncIsDefaultToCache(modelName, channelId, true)
return nil
})
}

// ClearDefaultChannel 清除指定模型的默认通道标记
func ClearDefaultChannel(modelName string) error {
err := DB.Model(&ChannelPricing{}).
Where("model_name = ? AND is_default = ?", modelName, true).
Update("is_default", false).Error
if err == nil {
clearDefaultChannelCache(modelName)
syncIsDefaultToCache(modelName, 0, false)
}
return err
}

+ 121
- 0
model/channel_pricing_test.go Visa fil

@@ -119,3 +119,124 @@ func TestGetEffectivePricing_NotFound(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
assert.Nil(t, found) assert.Nil(t, found)
} }

// === 默认通道测试 ===

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

// 创建两条定价记录
cp1 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 100, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0}
cp2 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 200, QuotaType: QuotaTypeByTokens, ModelRatio: 2.0}
require.NoError(t, cp1.Insert())
require.NoError(t, cp2.Insert())
t.Cleanup(func() { cp1.Delete(); cp2.Delete() })

// 初始没有默认
_, ok := GetDefaultChannelId("default-test-model")
assert.False(t, ok)

// 设置通道 100 为默认
require.NoError(t, SetDefaultChannel("default-test-model", 100))
id, ok := GetDefaultChannelId("default-test-model")
assert.True(t, ok)
assert.Equal(t, 100, id)

// 切换默认到通道 200
require.NoError(t, SetDefaultChannel("default-test-model", 200))
id, ok = GetDefaultChannelId("default-test-model")
assert.True(t, ok)
assert.Equal(t, 200, id)

// 验证旧的默认标记被清除
found, _ := GetEffectivePricing("default-test-model", 100)
assert.False(t, found.IsDefault)
found, _ = GetEffectivePricing("default-test-model", 200)
assert.True(t, found.IsDefault)
}

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

cp := &ChannelPricing{ModelName: "clear-test-model", ChannelId: 300, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0}
require.NoError(t, cp.Insert())
t.Cleanup(func() { cp.Delete() })

require.NoError(t, SetDefaultChannel("clear-test-model", 300))
_, ok := GetDefaultChannelId("clear-test-model")
assert.True(t, ok)

require.NoError(t, ClearDefaultChannel("clear-test-model"))
_, ok = GetDefaultChannelId("clear-test-model")
assert.False(t, ok)
}

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

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

id, ok := GetDefaultChannelId("delete-test-model")
assert.True(t, ok)
assert.Equal(t, 400, id)

// 删除记录后缓存应清除
require.NoError(t, cp.Delete())
_, ok = GetDefaultChannelId("delete-test-model")
assert.False(t, ok)
}

func TestDefaultChannel_LoadCache(t *testing.T) {
db := setupChannelPricingDB(t)

// 直接插入数据(绕过缓存)
db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 500, ModelRatio: 1.0, IsDefault: true})
db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 501, ModelRatio: 2.0, IsDefault: false})
db.Create(&ChannelPricing{ModelName: "other-model", ChannelId: 502, ModelRatio: 1.0, IsDefault: true})

// 全量加载缓存
LoadChannelPricingCache()

id, ok := GetDefaultChannelId("load-model")
assert.True(t, ok)
assert.Equal(t, 500, id)

id, ok = GetDefaultChannelId("other-model")
assert.True(t, ok)
assert.Equal(t, 502, id)

// 无默认的模型
_, ok = GetDefaultChannelId("nonexistent")
assert.False(t, ok)
}

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

cp := &ChannelPricing{ModelName: "insert-default-model", ChannelId: 600, ModelRatio: 1.0, IsDefault: true}
require.NoError(t, cp.Insert())
t.Cleanup(func() { cp.Delete() })

id, ok := GetDefaultChannelId("insert-default-model")
assert.True(t, ok)
assert.Equal(t, 600, id)
}

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

cp1 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 700, ModelRatio: 1.0, IsDefault: true}
cp2 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 701, ModelRatio: 2.0}
require.NoError(t, cp1.Insert())
require.NoError(t, cp2.Insert())
t.Cleanup(func() { cp1.Delete(); cp2.Delete() })

// cp1 是默认,通过 Update 把 cp2 设为默认
cp2.IsDefault = true
require.NoError(t, cp2.Update())

id, ok := GetDefaultChannelId("update-default-model")
assert.True(t, ok)
assert.Equal(t, 701, id)
}

+ 16
- 4
model/pricing.go Visa fil

@@ -31,6 +31,7 @@ type Pricing struct {
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
PricingVersion string `json:"pricing_version,omitempty"` PricingVersion string `json:"pricing_version,omitempty"`
Type int `json:"type"` Type int `json:"type"`
DefaultChannelName string `json:"default_channel_name,omitempty"`
} }


type PricingVendor struct { type PricingVendor struct {
@@ -271,16 +272,21 @@ func updatePricing() {
} }
} }


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


pricingMap = make([]Pricing, 0) pricingMap = make([]Pricing, 0)
@@ -306,6 +312,12 @@ func updatePricing() {


// 使用渠道定价表中的实际数据,选取最便宜的渠道 // 使用渠道定价表中的实际数据,选取最便宜的渠道
applyBestChannelPricing(&pricing, cpMap[model], model) applyBestChannelPricing(&pricing, cpMap[model], model)
// 填充默认通道名称
if chId, ok := GetDefaultChannelId(model); ok {
if name, found := channelNameMap[chId]; found {
pricing.DefaultChannelName = name
}
}


pricingMap = append(pricingMap, pricing) pricingMap = append(pricingMap, pricing)
} }


+ 2
- 0
router/api-router.go Visa fil

@@ -194,6 +194,8 @@ func SetApiRouter(router *gin.Engine) {
channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing) channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing)
channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing) channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing)
channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing) channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing)
channelPricingRoute.POST("/set_default", controller.SetDefaultChannel)
channelPricingRoute.DELETE("/default/*name", controller.ClearDefaultChannel)
} }


// 定价标签路由(管理员权限) // 定价标签路由(管理员权限)


+ 12
- 6
web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx Visa fil

@@ -104,7 +104,7 @@ const ChannelPricingCard = ({
const tableData = channelPricingData.map((item, index) => ({ const tableData = channelPricingData.map((item, index) => ({
key: item.channel_id || index, key: item.channel_id || index,
channelId: item.channel_id, channelId: item.channel_id,
channelName: `道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`,
channelName: `道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`,
channelTags: item.tags || [], // 渠道定价的标签列表 channelTags: item.tags || [], // 渠道定价的标签列表
channelType: item.channel_type, channelType: item.channel_type,
quotaType: item.quota_type, quotaType: item.quota_type,
@@ -114,6 +114,7 @@ const ChannelPricingCard = ({
hasCustomPricing: item.has_custom_pricing, hasCustomPricing: item.has_custom_pricing,
cacheRatio: item.cache_ratio, cacheRatio: item.cache_ratio,
cacheCreationRatio: item.cache_creation_ratio, cacheCreationRatio: item.cache_creation_ratio,
isDefault: item.is_default || false,
})); }));


// 判断是否存在按次计费的渠道 // 判断是否存在按次计费的渠道
@@ -124,11 +125,11 @@ const ChannelPricingCard = ({
// 定义基础列 // 定义基础列
const baseColumns = [ const baseColumns = [
{ {
title: t('道'),
title: t('道'),
dataIndex: 'channelName', dataIndex: 'channelName',
render: (text, record) => ( render: (text, record) => (
<div className='flex items-center gap-2 flex-wrap'> <div className='flex items-center gap-2 flex-wrap'>
<Tooltip content={t('点击复制道 ID')}>
<Tooltip content={t('点击复制道 ID')}>
<Tag <Tag
color='grey' color='grey'
size='small' size='small'
@@ -156,6 +157,11 @@ const ChannelPricingCard = ({
{tag.name} {tag.name}
</Tag> </Tag>
))} ))}
{record.isDefault && (
<Tag color='amber' size='small' shape='circle'>
{t('默认')}
</Tag>
)}
</div> </div>
), ),
}, },
@@ -282,15 +288,15 @@ const ChannelPricingCard = ({
<IconServer size={16} /> <IconServer size={16} />
</Avatar> </Avatar>
<div> <div>
<Text className='text-lg font-medium'>{t('道价格')}</Text>
<Text className='text-lg font-medium'>{t('道价格')}</Text>
<div className='text-xs text-gray-600'> <div className='text-xs text-gray-600'>
{t('所有支持该模型的道价格(自定义定价已标记)')}
{t('所有支持该模型的道价格(自定义定价已标记)')}
</div> </div>
</div> </div>
</div> </div>
<Banner <Banner
type='info' type='info'
description={t('在 API Key 后添加 ":渠道ID" 可指定使用特定渠道,如:sk-xxxx:1')}
description={t('在 API Key 后添加 ":通道ID" 可指定使用特定通道,如:sk-xxxx:1')}
className='mb-4' className='mb-4'
/> />
<Table <Table


+ 13
- 1
web/src/components/table/model-pricing/view/card/PricingCardView.jsx Visa fil

@@ -262,7 +262,7 @@ const PricingCardView = ({
<div className='flex items-start space-x-3 flex-1 min-w-0'> <div className='flex items-start space-x-3 flex-1 min-w-0'>
{getModelIcon(model)} {getModelIcon(model)}
<div className='flex-1 min-w-0'> <div className='flex-1 min-w-0'>
<h3 className='text-lg font-bold text-gray-900 truncate'>
<h3 className='text-lg font-bold text-gray-900 break-all'>
{model.model_name} {model.model_name}
</h3> </h3>
<div className='text-xs mt-1'> <div className='text-xs mt-1'>
@@ -312,6 +312,18 @@ const PricingCardView = ({
{/* 标签区域 */} {/* 标签区域 */}
{renderTags(model)} {renderTags(model)}


{/* 默认通道标识 */}
{model.default_channel_name && (
<Tag
color='amber'
size='small'
shape='circle'
style={{ marginTop: 4 }}
>
{t('默认')}: {model.default_channel_name}
</Tag>
)}

{/* 倍率信息(可选) */} {/* 倍率信息(可选) */}
{showRatio && ( {showRatio && (
<div className='pt-3'> <div className='pt-3'>


+ 8
- 0
web/src/helpers/api.js Visa fil

@@ -401,6 +401,14 @@ export const channelPricingApi = {
// 复制全局定价 // 复制全局定价
copyGlobal: (channelId, overwrite = false) => copyGlobal: (channelId, overwrite = false) =>
API.post(`/api/channel_pricing/copy_global/${channelId}`, { overwrite }), API.post(`/api/channel_pricing/copy_global/${channelId}`, { overwrite }),

// 设置默认通道
setDefault: (modelName, channelId) =>
API.post('/api/channel_pricing/set_default', { model_name: modelName, channel_id: channelId }),

// 清除默认通道
clearDefault: (modelName) =>
API.delete(`/api/channel_pricing/default/${encodeURIComponent(modelName)}`),
}; };


// 定价标签 API // 定价标签 API


+ 6
- 46
web/src/helpers/utils.jsx Visa fil

@@ -680,26 +680,9 @@ export const calculateModelPrice = ({
} }
} }


const cacheReadRatio = record.cache_ratio || 0;
const cacheCreationRatio = record.cache_creation_ratio || 0;
let cacheReadPrice = null;
let cacheCreationPrice = null;
if (cacheReadRatio > 0) {
const cacheReadUSD = record.model_ratio * cacheReadRatio * 2 * usedGroupRatio;
const numCacheRead = parseFloat(displayPrice(cacheReadUSD).replace(/[^0-9.]/g, '')) / unitDivisor;
cacheReadPrice = `${symbol}${numCacheRead.toFixed(precision)}`;
}
if (cacheCreationRatio > 0) {
const cacheCreationUSD = record.model_ratio * cacheCreationRatio * 2 * usedGroupRatio;
const numCacheCreation = parseFloat(displayPrice(cacheCreationUSD).replace(/[^0-9.]/g, '')) / unitDivisor;
cacheCreationPrice = `${symbol}${numCacheCreation.toFixed(precision)}`;
}

return { return {
inputPrice: `${symbol}${numInput.toFixed(precision)}`, inputPrice: `${symbol}${numInput.toFixed(precision)}`,
completionPrice: `${symbol}${numCompletion.toFixed(precision)}`, completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
cacheReadPrice,
cacheCreationPrice,
unitLabel, unitLabel,
isPerToken: true, isPerToken: true,
usedGroup, usedGroup,
@@ -733,39 +716,16 @@ export const calculateModelPrice = ({
export const formatPriceInfo = (priceData, t) => { export const formatPriceInfo = (priceData, t) => {
if (priceData.isPerToken) { if (priceData.isPerToken) {
return ( return (
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-2 flex-wrap'>
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('输入')} {priceData.inputPrice}/{priceData.unitLabel}
</span>
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('输出')} {priceData.completionPrice}/{priceData.unitLabel}
</span>
</div>
{(priceData.cacheReadPrice || priceData.cacheCreationPrice) && (
<div className='flex items-center gap-2 flex-wrap'>
{priceData.cacheReadPrice && (
<span style={{ color: 'var(--semi-color-text-2)' }}>
{t('缓存读取')} {priceData.cacheReadPrice}/{priceData.unitLabel}
</span>
)}
{priceData.cacheCreationPrice && (
<span style={{ color: 'var(--semi-color-text-2)' }}>
{t('缓存创建')} {priceData.cacheCreationPrice}/{priceData.unitLabel}
</span>
)}
</div>
)}
</div>
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('输入')} {priceData.inputPrice} / {t('输出')} {priceData.completionPrice} / {priceData.unitLabel} tokens
</span>
); );
} }


return ( return (
<>
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('模型价格')} {priceData.price}
</span>
</>
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('模型价格')} {priceData.price}
</span>
); );
}; };




+ 7
- 1
web/src/i18n/locales/en.json Visa fil

@@ -2230,9 +2230,15 @@
"统一的": "The Unified", "统一的": "The Unified",
"统一的大模型接口网关": "Unified LLM API Gateway", "统一的大模型接口网关": "Unified LLM API Gateway",
"链接顶级 AI 能力": "Connect Top-Tier AI", "链接顶级 AI 能力": "Connect Top-Tier AI",
"点击复制道 ID": "Click to copy channel ID",
"点击复制道 ID": "Click to copy channel ID",
"统一监控": "Unified Monitoring", "统一监控": "Unified Monitoring",
"统计Tokens": "Statistical Tokens", "统计Tokens": "Statistical Tokens",
"通道": "Channel",
"通道价格": "Channel Pricing",
"已设为默认通道": "Set as default channel",
"已取消默认通道": "Removed default channel",
"所有支持该模型的通道价格(自定义定价已标记)": "Pricing from all channels supporting this model (custom pricing marked)",
"在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1": "Append \":ChannelID\" to the API Key to use a specific channel, e.g. sk-xxxx:1",
"统计已重置": "Statistics reset", "统计已重置": "Statistics reset",
"统计次数": "Statistical count", "统计次数": "Statistical count",
"统计额度": "Statistical quota", "统计额度": "Statistical quota",


+ 7
- 1
web/src/i18n/locales/zh-CN.json Visa fil

@@ -2212,9 +2212,15 @@
"统一的": "统一的", "统一的": "统一的",
"统一的大模型接口网关": "统一的大模型接口网关", "统一的大模型接口网关": "统一的大模型接口网关",
"链接顶级 AI 能力": "链接顶级 AI 能力", "链接顶级 AI 能力": "链接顶级 AI 能力",
"点击复制渠道 ID": "点击复制渠道 ID",
"点击复制通道 ID": "点击复制通道 ID",
"统一监控": "统一监控", "统一监控": "统一监控",
"统计Tokens": "统计Tokens", "统计Tokens": "统计Tokens",
"通道": "通道",
"通道价格": "通道价格",
"已设为默认通道": "已设为默认通道",
"已取消默认通道": "已取消默认通道",
"所有支持该模型的通道价格(自定义定价已标记)": "所有支持该模型的通道价格(自定义定价已标记)",
"在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1": "在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1",
"统计已重置": "统计已重置", "统计已重置": "统计已重置",
"统计次数": "统计次数", "统计次数": "统计次数",
"统计额度": "统计额度", "统计额度": "统计额度",


+ 38
- 0
web/src/pages/Setting/Ratio/ChannelPricingView.jsx Visa fil

@@ -33,6 +33,7 @@ import {
RadioGroup, RadioGroup,
Radio, Radio,
TreeSelect, TreeSelect,
Tooltip,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconEdit, IconEdit,
@@ -40,6 +41,7 @@ import {
IconChevronDown, IconChevronDown,
IconChevronRight, IconChevronRight,
IconSearch, IconSearch,
IconStar,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { channelPricingApi } from '../../../helpers/api'; import { channelPricingApi } from '../../../helpers/api';
@@ -315,6 +317,42 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
)); ));
}, },
}, },
{
title: t('默认'),
key: 'isDefault',
width: 70,
render: (_, record) => {
const isDefault = record.pricing?.is_default === true;
return (
<Tooltip content={isDefault ? t('此为该模型的默认通道') : t('设为默认通道')}>
<div className="flex items-center gap-1">
<Button
size="small"
type={isDefault ? 'warning' : 'tertiary'}
icon={<IconStar />}
onClick={async () => {
try {
if (isDefault) {
await channelPricingApi.clearDefault(record.modelName);
showSuccess(t('已取消默认通道'));
} else {
await channelPricingApi.setDefault(record.modelName, record.channelId);
showSuccess(t('已设为默认通道'));
}
onRefresh();
} catch (e) {
showError(e);
}
}}
/>
{isDefault && (
<span className="text-xs text-amber-500 font-medium">{t('默认')}</span>
)}
</div>
</Tooltip>
);
},
},
{ {
title: t('操作'), title: t('操作'),
key: 'action', key: 'action',


Laddar…
Avbryt
Spara