From 8fc0eb78e263353cfb4d24380b20fac6b8e4a71d Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Apr 2026 20:07:48 +0800 Subject: [PATCH] feat(billing): enhance task billing process with video input detection and updated pricing logic - Added `EstimateBilling` function to check for video input in request metadata and return corresponding discount ratios. - Updated `ModelPriceHelperPerCall` to incorporate new pricing logic based on model ratios and video input. - Enhanced task billing logs to include model ratio information and adjusted calculations for actual quota based on additional multipliers. - Introduced `renderTaskBillingProcess` to improve rendering of task billing information in the UI. --- controller/relay.go | 2 +- relay/channel/task/doubao/adaptor.go | 43 ++++++++++++++++++ relay/channel/task/doubao/constants.go | 13 ++++++ relay/helper/price.go | 44 ++++++++++++------- service/task_billing.go | 24 ++++++++-- web/src/helpers/render.jsx | 12 +++++ web/src/hooks/usage-logs/useUsageLogsData.jsx | 6 ++- 7 files changed, 123 insertions(+), 21 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index 10dfd502..593b31b7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) { ModelRatio: relayInfo.PriceData.ModelRatio, OtherRatios: relayInfo.PriceData.OtherRatios, OriginModelName: relayInfo.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName), + PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice, } task.Quota = result.Quota task.Data = result.TaskData diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index e760e4c9..a6dabb5f 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -132,6 +132,49 @@ func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *r return nil } +// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。 +func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil + } + if hasVideoInMetadata(req.Metadata) { + if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok { + return map[string]float64{"video_input": ratio} + } + } + return nil +} + +// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目, +// 避免构建完整的上游 requestPayload。 +func hasVideoInMetadata(metadata map[string]interface{}) bool { + if metadata == nil { + return false + } + contentRaw, ok := metadata["content"] + if !ok { + return false + } + contentSlice, ok := contentRaw.([]interface{}) + if !ok { + return false + } + for _, item := range contentSlice { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if itemMap["type"] == "video_url" { + return true + } + if _, has := itemMap["video_url"]; has { + return true + } + } + return false +} + // BuildRequestBody converts request into Doubao specific format. func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { req, err := relaycommon.GetTaskRequest(c) diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go index 417fd587..d65773d3 100644 --- a/relay/channel/task/doubao/constants.go +++ b/relay/channel/task/doubao/constants.go @@ -10,3 +10,16 @@ var ModelList = []string{ } var ChannelName = "doubao-video" + +// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。 +// 管理员应将 ModelRatio 设置为"不含视频"的较高费率, +// 系统在检测到视频输入时自动乘以此折扣。 +var videoInputRatioMap = map[string]float64{ + "doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087 + "doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946 +} + +func GetVideoInputRatio(modelName string) (float64, bool) { + r, ok := videoInputRatioMap[modelName] + return r, ok +} diff --git a/relay/helper/price.go b/relay/helper/price.go index f109040d..e9c3b463 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -139,21 +139,23 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens return priceData, nil } -// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task) +// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task) func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { groupRatioInfo := HandleGroupRatio(c, info) modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) - // 如果没有配置价格,检查模型倍率配置 - if !success { + usePrice := success + var modelRatio float64 - // 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用 + if !success { defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] if ok { modelPrice = defaultPrice + usePrice = true } else { - // 没有配置倍率也不接受没配置,那就返回错误 - _, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName) + var ratioSuccess bool + var matchName string + modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName) acceptUnsetRatio := false if info.UserSetting.AcceptUnsetRatioModel { acceptUnsetRatio = true @@ -161,25 +163,37 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types if !ratioSuccess && !acceptUnsetRatio { return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) } - // 未配置价格但配置了倍率,使用默认预扣价格 - modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit } - } - quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) - // 免费模型检测(与 ModelPriceHelper 对齐) + var quota int freeModel := false - if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { - if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { - quota = 0 - freeModel = true + + if usePrice { + quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { + if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { + quota = 0 + freeModel = true + } + } + } else { + // 按量计费:以模型倍率的一半作为预扣额度 + quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + modelPrice = -1 + if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { + if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 { + quota = 0 + freeModel = true + } } } priceData := types.PriceData{ FreeModel: freeModel, ModelPrice: modelPrice, + ModelRatio: modelRatio, + UsePrice: usePrice, Quota: quota, GroupRatioInfo: groupRatioInfo, } diff --git a/service/task_billing.go b/service/task_billing.go index b887f668..e5c406dd 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { } } other := make(map[string]interface{}) + other["is_task"] = true other["request_path"] = c.Request.URL.Path other["model_price"] = info.PriceData.ModelPrice + if info.PriceData.ModelRatio > 0 { + other["model_ratio"] = info.PriceData.ModelRatio + } other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio if info.PriceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio @@ -117,6 +121,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} { other := make(map[string]interface{}) if bc := task.PrivateData.BillingContext; bc != nil { other["model_price"] = bc.ModelPrice + if bc.ModelRatio > 0 { + other["model_ratio"] = bc.ModelRatio + } other["group_ratio"] = bc.GroupRatio if len(bc.OtherRatios) > 0 { for k, v := range bc.OtherRatios { @@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int } other := taskBillingOther(task) other["task_id"] = task.TaskID - //other["reason"] = reason other["pre_consumed_quota"] = preConsumedQuota other["actual_quota"] = actualQuota model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ @@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo finalGroupRatio = groupRatio } - // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio - actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio) + // 计算 OtherRatios 乘积(视频折扣、时长等) + otherMultiplier := 1.0 + if bc := task.PrivateData.BillingContext; bc != nil { + for _, r := range bc.OtherRatios { + if r != 1.0 && r > 0 { + otherMultiplier *= r + } + } + } - reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio) + // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier + actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier) + + reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier) RecalculateTaskQuota(ctx, task, actualQuota, reason) } diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 28da657f..0ad16bca 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1620,6 +1620,18 @@ function renderPriceSimpleCore({ return result; } +export function renderTaskBillingProcess(other, content) { + if (other?.task_id != null) { + return renderBillingArticle( + [content].filter(Boolean), + { showReferenceNote: false }, + ); + } + return renderBillingArticle([ + buildBillingText('任务预扣费(将在任务完成后按实际token重算)'), + ]); +} + export function renderModelPrice( inputTokens, completionTokens, diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index a9ffaba0..e406b2ab 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -36,6 +36,7 @@ import { renderAudioModelPrice, renderClaudeModelPrice, renderModelPrice, + renderTaskBillingProcess, } from '../../helpers'; import { ITEMS_PER_PAGE } from '../../constants'; import { useTableCompactMode } from '../common/useTableCompactMode'; @@ -497,7 +498,10 @@ export const useLogsData = () => { let content = ''; if (!isViolationFeeLog) { - if (other?.ws || other?.audio) { + const isTaskLog = other?.is_task === true || other?.task_id != null; + if (isTaskLog && other?.model_price === -1) { + content = renderTaskBillingProcess(other, logs[i].content); + } else if (other?.ws || other?.audio) { content = renderAudioModelPrice( other?.text_input, other?.text_output,