Parcourir la source

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.
CaIon il y a 2 mois
Parent
commit
8fc0eb78e2

+ 1 - 1
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

+ 43 - 0
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)

+ 13 - 0
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
+}

+ 29 - 15
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,
 	}

+ 20 - 4
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
+			}
+		}
+	}
+
+	// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier
+	actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
 
-	reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
+	reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
 	RecalculateTaskQuota(ctx, task, actualQuota, reason)
 }

+ 12 - 0
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,

+ 5 - 1
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,