From 58dc2e41f781328cf085d24bffee87363f558e2d Mon Sep 17 00:00:00 2001 From: monlor <20394007+monlor@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:25:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20add=20per-channel=20billing=20?= =?UTF-8?q?ratio=20(=E6=B8=A0=E9=81=93=E5=80=8D=E7=8E=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a configurable `ratio` field to channels so that calls routed through different channels for the same model can carry different billing multipliers. Billing formula: quota = tokens × modelRatio × groupRatio × channelRatio - Backend: Channel.Ratio field (GORM auto-migrate, default 1.0); ratio propagated via gin context key set by distributor middleware, read directly in ModelPriceHelper/ModelPriceHelperPerCall before InitChannelMeta is called (fixes timing bug where ChannelMeta was nil); channelRatio applied in PostTextConsumeQuota after all quota sources (token-based and tiered) are resolved to avoid tiered-overwrite bug; upstream_quota (pre-channelRatio) stored in log other for display; audio/WSS/task billing paths updated; log_info_generate emits channel_ratio when != 1.0 - Pricing API: AbilityWithChannel now carries channel_ratio/weight; updatePricing tracks min/max channel ratio per (model,group) and exposes group_channel_ratio_min/max in /api/pricing response - Frontend: channel form gains a ratio input (min 0.01, clears to 1); model marketplace shows price range "$X ~ $Y /M" when channels in the same group carry different ratios, single price otherwise; usage-log detail dialog shows upstream_cost (before channelRatio) and total cost separately when channelRatio != 1; channel ratio 0 treated as 1.0 consistently (|| 1 instead of ?? 1); zh.json translations added Co-Authored-By: Claude Sonnet 4.6 --- constant/context_key.go | 1 + controller/relay.go | 1 + middleware/distributor.go | 1 + model/ability.go | 6 +- model/channel.go | 11 +++ model/pricing.go | 61 +++++++++++++ model/task.go | 1 + pkg/billingexpr/settle.go | 6 +- pkg/billingexpr/types.go | 1 + relay/common/relay_info.go | 7 ++ relay/helper/price.go | 31 +++++-- service/log_info_generate.go | 6 ++ service/quota.go | 39 +++++--- service/task_billing.go | 16 +++- service/text_quota.go | 21 +++++ types/price_data.go | 1 + .../drawers/channel-mutate-drawer.tsx | 28 ++++++ .../src/features/channels/lib/channel-form.ts | 5 ++ web/default/src/features/pricing/lib/price.ts | 89 +++++++++++-------- web/default/src/features/pricing/types.ts | 10 +++ .../components/dialogs/details-dialog.tsx | 22 +++++ web/default/src/features/usage-logs/types.ts | 2 + web/default/src/i18n/locales/zh.json | 2 + 23 files changed, 303 insertions(+), 65 deletions(-) diff --git a/constant/context_key.go b/constant/context_key.go index b856bc3dda14..14e602720bc1 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -21,6 +21,7 @@ const ( ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" /* channel related keys */ + ContextKeyChannelRatio ContextKey = "channel_ratio" ContextKeyChannelId ContextKey = "channel_id" ContextKeyChannelName ContextKey = "channel_name" ContextKeyChannelCreateTime ContextKey = "channel_create_time" diff --git a/controller/relay.go b/controller/relay.go index 1d14dcc6f880..6210ff916e62 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -584,6 +584,7 @@ func RelayTask(c *gin.Context) { task.PrivateData.BillingContext = &model.TaskBillingContext{ ModelPrice: relayInfo.PriceData.ModelPrice, GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, + ChannelRatio: relayInfo.PriceData.ChannelRatio, ModelRatio: relayInfo.PriceData.ModelRatio, OtherRatios: relayInfo.PriceData.OtherRatios, OriginModelName: relayInfo.OriginModelName, diff --git a/middleware/distributor.go b/middleware/distributor.go index 258aebb57037..d6d4a88dfba7 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -485,6 +485,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode case constant.ChannelTypeCoze: c.Set("bot_id", channel.Other) } + common.SetContextKey(c, constant.ContextKeyChannelRatio, channel.GetRatio()) return nil } diff --git a/model/ability.go b/model/ability.go index 1d7c53fa5805..d4bdc418cd62 100644 --- a/model/ability.go +++ b/model/ability.go @@ -25,13 +25,15 @@ type Ability struct { type AbilityWithChannel struct { Ability - ChannelType int `json:"channel_type"` + ChannelType int `json:"channel_type"` + ChannelRatio float64 `json:"channel_ratio"` + ChannelWeight uint `json:"channel_weight"` } func GetAllEnableAbilityWithChannels() ([]AbilityWithChannel, error) { var abilities []AbilityWithChannel err := DB.Table("abilities"). - Select("abilities.*, channels.type as channel_type"). + Select("abilities.*, channels.type as channel_type, COALESCE(channels.ratio, 1) as channel_ratio, COALESCE(channels.weight, 0) as channel_weight"). Joins("left join channels on abilities.channel_id = channels.id"). Where("abilities.enabled = ?", true). Scan(&abilities).Error diff --git a/model/channel.go b/model/channel.go index 78a1477c327e..913f8214d204 100644 --- a/model/channel.go +++ b/model/channel.go @@ -55,6 +55,10 @@ type Channel struct { OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings + // Ratio is a per-channel billing multiplier applied on top of model and group ratios. + // nil or 0 is treated as 1.0 (no adjustment). Values < 1 reduce cost, > 1 increase cost. + Ratio *float64 `json:"ratio" gorm:"default:1"` + // cache info Keys []string `json:"-" gorm:"-"` } @@ -342,6 +346,13 @@ func (channel *Channel) GetAutoBan() bool { return *channel.AutoBan == 1 } +func (channel *Channel) GetRatio() float64 { + if channel.Ratio == nil || *channel.Ratio == 0 { + return 1.0 + } + return *channel.Ratio +} + func (channel *Channel) Save() error { return DB.Save(channel).Error } diff --git a/model/pricing.go b/model/pricing.go index b9574a388587..b0887e7f6159 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -36,6 +36,12 @@ type Pricing struct { BillingMode string `json:"billing_mode,omitempty"` BillingExpr string `json:"billing_expr,omitempty"` PricingVersion string `json:"pricing_version,omitempty"` + // GroupChannelRatioMin holds the minimum channel ratio per group across all channels serving that model+group. + // Omitted when all channels use the default ratio of 1.0. + GroupChannelRatioMin map[string]float64 `json:"group_channel_ratio_min,omitempty"` + // GroupChannelRatioMax holds the maximum channel ratio per group. + // Omitted when equal to GroupChannelRatioMin (no range to show). + GroupChannelRatioMax map[string]float64 `json:"group_channel_ratio_max,omitempty"` } type PricingVendor struct { @@ -190,6 +196,13 @@ func updatePricing() { modelGroupsMap := make(map[string]*types.Set[string]) + // modelGroupChannelRatio[model][group] = {min, max} channel ratio across all channels + type minMaxRatio struct { + min float64 + max float64 + } + modelGroupChannelRatio := make(map[string]map[string]*minMaxRatio) + for _, ability := range enableAbilities { groups, ok := modelGroupsMap[ability.Model] if !ok { @@ -197,6 +210,25 @@ func updatePricing() { modelGroupsMap[ability.Model] = groups } groups.Add(ability.Group) + + // accumulate min/max channel ratio per (model, group) + if _, ok := modelGroupChannelRatio[ability.Model]; !ok { + modelGroupChannelRatio[ability.Model] = make(map[string]*minMaxRatio) + } + cr := ability.ChannelRatio + if cr <= 0 { + cr = 1.0 + } + if mm, exists := modelGroupChannelRatio[ability.Model][ability.Group]; exists { + if cr < mm.min { + mm.min = cr + } + if cr > mm.max { + mm.max = cr + } + } else { + modelGroupChannelRatio[ability.Model][ability.Group] = &minMaxRatio{min: cr, max: cr} + } } //这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点 @@ -337,6 +369,35 @@ func updatePricing() { pricing.BillingExpr = expr } } + + // compute per-group min/max channel ratio + if groupRatios, ok := modelGroupChannelRatio[model]; ok { + minMap := make(map[string]float64) + maxMap := make(map[string]float64) + for group, mm := range groupRatios { + if mm.min != 1.0 || mm.max != 1.0 { + minMap[group] = mm.min + maxMap[group] = mm.max + } + } + if len(minMap) > 0 { + pricing.GroupChannelRatioMin = minMap + } + if len(maxMap) > 0 { + // only store max when it differs from min somewhere + hasRange := false + for g, mx := range maxMap { + if mx != minMap[g] { + hasRange = true + break + } + } + if hasRange { + pricing.GroupChannelRatioMax = maxMap + } + } + } + pricingMap = append(pricingMap, pricing) } diff --git a/model/task.go b/model/task.go index 5d00de51339f..9d9a19f6ae7e 100644 --- a/model/task.go +++ b/model/task.go @@ -111,6 +111,7 @@ type TaskPrivateData struct { type TaskBillingContext struct { ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 + ChannelRatio float64 `json:"channel_ratio,omitempty"` // 渠道倍率 ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName diff --git a/pkg/billingexpr/settle.go b/pkg/billingexpr/settle.go index 7a6ca4401430..12db93c58af3 100644 --- a/pkg/billingexpr/settle.go +++ b/pkg/billingexpr/settle.go @@ -23,7 +23,11 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re } quotaBeforeGroup := quotaConversion(cost, snap) - afterGroup := QuotaRound(quotaBeforeGroup * snap.GroupRatio) + cr := snap.ChannelRatio + if cr <= 0 { + cr = 1.0 + } + afterGroup := QuotaRound(quotaBeforeGroup * snap.GroupRatio * cr) crossed := trace.MatchedTier != snap.EstimatedTier return TieredResult{ diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go index 12e0d3c68599..c89657f35cb2 100644 --- a/pkg/billingexpr/types.go +++ b/pkg/billingexpr/types.go @@ -42,6 +42,7 @@ type BillingSnapshot struct { ExprString string `json:"expr_string"` ExprHash string `json:"expr_hash"` GroupRatio float64 `json:"group_ratio"` + ChannelRatio float64 `json:"channel_ratio"` EstimatedPromptTokens int `json:"estimated_prompt_tokens"` EstimatedCompletionTokens int `json:"estimated_completion_tokens"` EstimatedQuotaBeforeGroup float64 `json:"estimated_quota_before_group"` diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 2f7afd398599..880f26413bf4 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -78,6 +78,7 @@ type ChannelMeta struct { UpstreamModelName string IsModelMapped bool SupportStreamOptions bool // 是否支持流式选项 + ChannelRatio float64 } type TokenCountMeta struct { @@ -232,6 +233,12 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) { channelMeta.SupportStreamOptions = true } + channelRatio, ok := common.GetContextKeyType[float64](c, constant.ContextKeyChannelRatio) + if !ok || channelRatio <= 0 { + channelRatio = 1.0 + } + channelMeta.ChannelRatio = channelRatio + info.ChannelMeta = channelMeta // reset some fields based on channel meta diff --git a/relay/helper/price.go b/relay/helper/price.go index d1e16bca6085..703a6e7d7c85 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/billingexpr" @@ -35,6 +36,14 @@ func modelPriceNotConfiguredError(modelName string, userId int) error { // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration const claudeCacheCreation1hMultiplier = 6 / 3.75 +func getChannelRatioFromContext(c *gin.Context) float64 { + v, ok := common.GetContextKeyType[float64](c, constant.ContextKeyChannelRatio) + if !ok || v <= 0 { + return 1.0 + } + return v +} + // HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.GroupRatioInfo { groupRatioInfo := types.GroupRatioInfo{ @@ -69,9 +78,11 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens groupRatioInfo := HandleGroupRatio(c, info) + channelRatio := getChannelRatioFromContext(c) + // Check if this model uses tiered_expr billing if billing_setting.GetBillingMode(info.OriginModelName) == billing_setting.BillingModeTieredExpr { - return modelPriceHelperTiered(c, info, promptTokens, meta, groupRatioInfo) + return modelPriceHelperTiered(c, info, promptTokens, meta, groupRatioInfo, channelRatio) } var preConsumedQuota int @@ -111,13 +122,13 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) - ratio := modelRatio * groupRatioInfo.GroupRatio + ratio := modelRatio * groupRatioInfo.GroupRatio * channelRatio preConsumedQuota = int(float64(preConsumedTokens) * ratio) } else { if meta.ImagePriceRatio != 0 { modelPrice = modelPrice * meta.ImagePriceRatio } - preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio * channelRatio) } // check if free model pre-consume is disabled @@ -145,6 +156,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens ModelRatio: modelRatio, CompletionRatio: completionRatio, GroupRatioInfo: groupRatioInfo, + ChannelRatio: channelRatio, UsePrice: usePrice, CacheRatio: cacheRatio, ImageRatio: imageRatio, @@ -167,6 +179,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { groupRatioInfo := HandleGroupRatio(c, info) + channelRatio := getChannelRatioFromContext(c) + modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) usePrice := success var modelRatio float64 @@ -194,7 +208,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types freeModel := false if usePrice { - quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio * channelRatio) if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { quota = 0 @@ -203,7 +217,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } } else { // 按量计费:以模型倍率的一半作为预扣额度 - quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio * channelRatio) modelPrice = -1 if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 { @@ -219,6 +233,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types ModelRatio: modelRatio, UsePrice: usePrice, Quota: quota, + ChannelRatio: channelRatio, GroupRatioInfo: groupRatioInfo, } return priceData, nil @@ -238,7 +253,7 @@ func HasModelBillingConfig(modelName string) bool { return ok && strings.TrimSpace(expr) != "" } -func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo types.GroupRatioInfo) (types.PriceData, error) { +func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo types.GroupRatioInfo, channelRatio float64) (types.PriceData, error) { exprStr, ok := billing_setting.GetBillingExpr(info.OriginModelName) if !ok { return types.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", info.OriginModelName) @@ -265,7 +280,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT // Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does. quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit - preConsumedQuota := billingexpr.QuotaRound(quotaBeforeGroup * groupRatioInfo.GroupRatio) + preConsumedQuota := billingexpr.QuotaRound(quotaBeforeGroup * groupRatioInfo.GroupRatio * channelRatio) freeModel := false if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { @@ -282,6 +297,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT ExprString: exprStr, ExprHash: exprHash, GroupRatio: groupRatioInfo.GroupRatio, + ChannelRatio: channelRatio, EstimatedPromptTokens: promptTokens, EstimatedCompletionTokens: estimatedCompletionTokens, EstimatedQuotaBeforeGroup: quotaBeforeGroup, @@ -296,6 +312,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT priceData := types.PriceData{ FreeModel: freeModel, GroupRatioInfo: groupRatioInfo, + ChannelRatio: channelRatio, QuotaToPreConsume: preConsumedQuota, } diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 54448d59d673..7766a1560439 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -43,6 +43,9 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m other["cache_ratio"] = cacheRatio other["model_price"] = modelPrice other["user_group_ratio"] = userGroupRatio + if channelRatio := relayInfo.PriceData.ChannelRatio; channelRatio != 0 && channelRatio != 1.0 { + other["channel_ratio"] = channelRatio + } other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) if relayInfo.ReasoningEffort != "" { other["reasoning_effort"] = relayInfo.ReasoningEffort @@ -261,6 +264,9 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price if priceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio } + if priceData.ChannelRatio != 0 && priceData.ChannelRatio != 1.0 { + other["channel_ratio"] = priceData.ChannelRatio + } appendRequestPath(nil, relayInfo, other) return other } diff --git a/service/quota.go b/service/quota.go index 862805d7cdb0..3610de224423 100644 --- a/service/quota.go +++ b/service/quota.go @@ -37,6 +37,7 @@ type QuotaInfo struct { ModelPrice float64 ModelRatio float64 GroupRatio float64 + ChannelRatio float64 } func hasCustomModelRatio(modelName string, currentRatio float64) bool { @@ -48,12 +49,18 @@ func hasCustomModelRatio(modelName string, currentRatio float64) bool { } func calculateAudioQuota(info QuotaInfo) int { + channelRatio := info.ChannelRatio + if channelRatio <= 0 { + channelRatio = 1.0 + } + if info.UsePrice { modelPrice := decimal.NewFromFloat(info.ModelPrice) quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) groupRatio := decimal.NewFromFloat(info.GroupRatio) + cr := decimal.NewFromFloat(channelRatio) - quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio) + quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio).Mul(cr) return int(quota.IntPart()) } @@ -63,7 +70,8 @@ func calculateAudioQuota(info QuotaInfo) int { groupRatio := decimal.NewFromFloat(info.GroupRatio) modelRatio := decimal.NewFromFloat(info.ModelRatio) - ratio := groupRatio.Mul(modelRatio) + cr := decimal.NewFromFloat(channelRatio) + ratio := groupRatio.Mul(modelRatio).Mul(cr) inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens)) outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens)) @@ -130,10 +138,11 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag TextTokens: textOutTokens, AudioTokens: audioOutTokens, }, - ModelName: modelName, - UsePrice: relayInfo.UsePrice, - ModelRatio: modelRatio, - GroupRatio: actualGroupRatio, + ModelName: modelName, + UsePrice: relayInfo.UsePrice, + ModelRatio: modelRatio, + GroupRatio: actualGroupRatio, + ChannelRatio: relayInfo.ChannelMeta.ChannelRatio, } quota := calculateAudioQuota(quotaInfo) @@ -193,10 +202,11 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod TextTokens: textOutTokens, AudioTokens: audioOutTokens, }, - ModelName: modelName, - UsePrice: usePrice, - ModelRatio: modelRatio, - GroupRatio: groupRatio, + ModelName: modelName, + UsePrice: usePrice, + ModelRatio: modelRatio, + GroupRatio: groupRatio, + ChannelRatio: relayInfo.PriceData.ChannelRatio, } quota := calculateAudioQuota(quotaInfo) @@ -314,10 +324,11 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u TextTokens: textOutTokens, AudioTokens: audioOutTokens, }, - ModelName: relayInfo.OriginModelName, - UsePrice: usePrice, - ModelRatio: modelRatio, - GroupRatio: groupRatio, + ModelName: relayInfo.OriginModelName, + UsePrice: usePrice, + ModelRatio: modelRatio, + GroupRatio: groupRatio, + ChannelRatio: relayInfo.PriceData.ChannelRatio, } quota := calculateAudioQuota(quotaInfo) diff --git a/service/task_billing.go b/service/task_billing.go index 6cf7a965c8eb..05fdbe643021 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -46,6 +46,9 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { if info.PriceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio } + if info.PriceData.ChannelRatio != 0 && info.PriceData.ChannelRatio != 1.0 { + other["channel_ratio"] = info.PriceData.ChannelRatio + } if info.IsModelMapped { other["is_model_mapped"] = true other["upstream_model_name"] = info.UpstreamModelName @@ -130,6 +133,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} { other[k] = v } } + if bc.ChannelRatio != 0 && bc.ChannelRatio != 1.0 { + other["channel_ratio"] = bc.ChannelRatio + } } props := task.Properties if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName { @@ -285,17 +291,21 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo // 计算 OtherRatios 乘积(视频折扣、时长等) otherMultiplier := 1.0 + channelRatio := 1.0 if bc := task.PrivateData.BillingContext; bc != nil { for _, r := range bc.OtherRatios { if r != 1.0 && r > 0 { otherMultiplier *= r } } + if bc.ChannelRatio > 0 { + channelRatio = bc.ChannelRatio + } } - // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier - actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier) + // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * channelRatio * otherMultiplier + actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * channelRatio * otherMultiplier) - reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier) + reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, channelRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, channelRatio, otherMultiplier) RecalculateTaskQuota(ctx, task, actualQuota, reason) } diff --git a/service/text_quota.go b/service/text_quota.go index 3f344dc3e57b..039e776172da 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -55,6 +55,8 @@ type textQuotaSummary struct { AudioInputPrice float64 ImageGenerationCallPrice float64 ToolCallSurchargeQuota decimal.Decimal + ChannelRatio float64 + UpstreamQuota int // quota before channel ratio } func cacheWriteTokensTotal(summary textQuotaSummary) int { @@ -306,6 +308,13 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf summary.Quota = 1 } + // record channel ratio for later application (after tiered settlement) + channelRatio := relayInfo.PriceData.ChannelRatio + if channelRatio <= 0 { + channelRatio = 1.0 + } + summary.ChannelRatio = channelRatio + return summary } @@ -346,6 +355,15 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us } } + // apply channel ratio after all quota sources (token-based and tiered) are resolved + if summary.ChannelRatio != 1.0 { + summary.UpstreamQuota = summary.Quota + summary.Quota = int(decimal.NewFromInt(int64(summary.Quota)).Mul(decimal.NewFromFloat(summary.ChannelRatio)).Round(0).IntPart()) + if summary.UpstreamQuota > 0 && summary.Quota == 0 { + summary.Quota = 1 + } + } + if summary.WebSearchCallCount > 0 { extraContent = append(extraContent, fmt.Sprintf("Web Search 调用 %d 次,调用花费 %s", summary.WebSearchCallCount, decimal.NewFromFloat(summary.WebSearchPrice).Mul(decimal.NewFromInt(int64(summary.WebSearchCallCount))).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String())) } @@ -401,6 +419,9 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us if adminRejectReason != "" { other["reject_reason"] = adminRejectReason } + if summary.ChannelRatio != 0 && summary.ChannelRatio != 1.0 { + other["upstream_quota"] = summary.UpstreamQuota + } if summary.ImageTokens != 0 { other["image"] = true other["image_ratio"] = summary.ImageRatio diff --git a/types/price_data.go b/types/price_data.go index 93bc6ae8d168..610a37505bee 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -21,6 +21,7 @@ type PriceData struct { AudioRatio float64 AudioCompletionRatio float64 OtherRatios map[string]float64 + ChannelRatio float64 UsePrice bool Quota int // 按次计费的最终额度(MJ / Task) QuotaToPreConsume int // 按量计费的预消耗额度 diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 87dcc84afea6..fc113a4049cf 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -2560,6 +2560,34 @@ export function ChannelMutateDrawer({ /> + ( + + {t('Channel Ratio')} + + + field.onChange(Number(e.target.value) || 1) + } + /> + + + {t( + 'A billing multiplier. Lower ratios mean lower API call costs.' + )} + + + + )} + /> + + groupRatio: Record, + groupChannelRatioMin?: Record ): number { if (enableGroups.length === 0) return 1 let minRatio = Number.POSITIVE_INFINITY for (const group of enableGroups) { - const ratio = groupRatio[group] - if (ratio !== undefined && ratio < minRatio) { - minRatio = ratio + const gr = groupRatio[group] + if (gr === undefined) continue + const cr = groupChannelRatioMin?.[group] || 1 + const effective = gr * cr + if (effective < minRatio) { + minRatio = effective } } @@ -176,7 +180,11 @@ export function formatPrice( ? model.enable_groups : [] const groupRatio = model.group_ratio || {} - const minRatio = getMinGroupRatio(enableGroups, groupRatio) + const minRatio = getMinEffectiveRatio( + enableGroups, + groupRatio, + model.group_channel_ratio_min + ) let priceInUSD = calculateTokenPrice(model, type, minRatio) priceInUSD = applyRechargeRate( @@ -195,7 +203,7 @@ export function formatPrice( } /** - * Format price for a specific group (token-based) + * Format price for a specific group (token-based), returns range string when channels differ */ export function formatGroupPrice( model: PricingModel, @@ -211,26 +219,28 @@ export function formatGroupPrice( return '-' } - const ratio = groupRatio[group] || 1 - let priceInUSD = calculateTokenPrice(model, type, ratio) - - priceInUSD = applyRechargeRate( - priceInUSD, - showWithRecharge, - priceRate, - usdExchangeRate - ) + const gr = groupRatio[group] || 1 + const crMin = model.group_channel_ratio_min?.[group] || 1 + const crMax = model.group_channel_ratio_max?.[group] || crMin + + const fmt = (ratio: number) => { + let p = calculateTokenPrice(model, type, ratio) + p = applyRechargeRate(p, showWithRecharge, priceRate, usdExchangeRate) + return formatCurrencyFromUSD(p / TOKEN_UNIT_DIVISORS[tokenUnit], { + digitsLarge: 4, + digitsSmall: 6, + abbreviate: false, + }) + } - const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit] - return formatCurrencyFromUSD(price, { - digitsLarge: 4, - digitsSmall: 6, - abbreviate: false, - }) + const lo = fmt(gr * crMin) + if (crMin === crMax) return lo + const hi = fmt(gr * crMax) + return `${lo} ~ ${hi}` } /** - * Format fixed price for pay-per-request models (with specific group) + * Format fixed price for pay-per-request models (with specific group), returns range when channels differ */ export function formatFixedPrice( model: PricingModel, @@ -244,21 +254,20 @@ export function formatFixedPrice( return '-' } - const ratio = groupRatio[group] || 1 - let priceInUSD = (model.model_price || 0) * ratio + const gr = groupRatio[group] || 1 + const crMin = model.group_channel_ratio_min?.[group] || 1 + const crMax = model.group_channel_ratio_max?.[group] || crMin - priceInUSD = applyRechargeRate( - priceInUSD, - showWithRecharge, - priceRate, - usdExchangeRate - ) + const fmt = (ratio: number) => { + let p = (model.model_price || 0) * ratio + p = applyRechargeRate(p, showWithRecharge, priceRate, usdExchangeRate) + return formatCurrencyFromUSD(p, { digitsLarge: 4, digitsSmall: 4, abbreviate: false }) + } - return formatCurrencyFromUSD(priceInUSD, { - digitsLarge: 4, - digitsSmall: 4, - abbreviate: false, - }) + const lo = fmt(gr * crMin) + if (crMin === crMax) return lo + const hi = fmt(gr * crMax) + return `${lo} ~ ${hi}` } /** @@ -278,7 +287,11 @@ export function formatRequestPrice( ? model.enable_groups : [] const groupRatio = model.group_ratio || {} - const minRatio = getMinGroupRatio(enableGroups, groupRatio) + const minRatio = getMinEffectiveRatio( + enableGroups, + groupRatio, + model.group_channel_ratio_min + ) let priceInUSD = (model.model_price || 0) * minRatio diff --git a/web/default/src/features/pricing/types.ts b/web/default/src/features/pricing/types.ts index 9ee69cd79cfd..28404c4064f3 100644 --- a/web/default/src/features/pricing/types.ts +++ b/web/default/src/features/pricing/types.ts @@ -56,6 +56,16 @@ export type PricingModel = { billing_expr?: string /** Pricing version returned by backend, useful for cache busting */ pricing_version?: string + /** + * Minimum channel ratio per group (best price a user can get). + * Omitted when all channels use ratio 1.0. + */ + group_channel_ratio_min?: Record + /** + * Maximum channel ratio per group. Only present when channels in the same + * group carry different ratios (i.e. a price range exists). + */ + group_channel_ratio_max?: Record /** * Optional model metadata fields. These are not yet returned by the backend * and are populated client-side from {@link inferModelMetadata}. diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx index 87571fe9e797..1ac6b1327389 100644 --- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -217,6 +217,17 @@ function BillingBreakdown(props: { }) } + if ( + other.channel_ratio != null && + Number.isFinite(other.channel_ratio) && + other.channel_ratio !== 1 + ) { + rows.push({ + label: t('Channel Ratio'), + value: `${formatRatio(other.channel_ratio)}x`, + }) + } + if (!isTieredExpr && isClaude && hasAnyCacheTokens(other)) { if (other.cache_ratio != null && other.cache_ratio !== 1) { rows.push({ @@ -316,6 +327,17 @@ function BillingBreakdown(props: { }) } + if ( + other.upstream_quota != null && + other.channel_ratio != null && + other.channel_ratio !== 1 + ) { + rows.push({ + label: t('Upstream Cost'), + value: formatLogQuota(other.upstream_quota), + }) + } + rows.push({ label: t('Total Cost'), value: formatLogQuota(log.quota), diff --git a/web/default/src/features/usage-logs/types.ts b/web/default/src/features/usage-logs/types.ts index b243aff3f878..a0c9d266f13f 100644 --- a/web/default/src/features/usage-logs/types.ts +++ b/web/default/src/features/usage-logs/types.ts @@ -148,6 +148,7 @@ export interface LogOtherData { model_price?: number group_ratio?: number user_group_ratio?: number + channel_ratio?: number cache_ratio?: number cache_creation_ratio?: number cache_creation_ratio_5m?: number @@ -194,6 +195,7 @@ export interface LogOtherData { violation_fee_code?: string violation_fee_marker?: string fee_quota?: number + upstream_quota?: number // Reject / intercept reason (admin) reject_reason?: string // Task-related fields (for refund logs, type=6) diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 5ca604561ace..7593196fcf6b 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -649,6 +649,7 @@ "Changes are written to the settings draft on save.": "保存后会写入设置草稿。", "Changing...": "修改中...", "Channel": "渠道", + "Channel Ratio": "渠道倍率", "Channel Affinity": "渠道亲和性", "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。", "Channel Affinity: Upstream Cache Hit": "渠道亲和性:上游缓存命中", @@ -4145,6 +4146,7 @@ "Total consumed quota": "总消耗额度", "Total cost": "总成本", "Total Cost": "总费用", + "Upstream Cost": "上游费用", "Total Count": "总数", "Total earned": "累计获得", "Total Earned": "总收入",