package helper import ( "fmt" "math" "strconv" "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" "github.com/shopspring/decimal" ) // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration const claudeCacheCreation1hMultiplier = 6 / 3.75 func resolveSupplierIDByChannel(info *relaycommon.RelayInfo) int { if info == nil || info.ChannelMeta == nil || info.ChannelId <= 0 { return 0 } channel, err := model.CacheGetChannel(info.ChannelId) if err != nil || channel == nil { return 0 } return channel.SupplierApplicationID } // 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{ GroupRatio: 1.0, // default ratio GroupSpecialRatio: -1, } // check auto group autoGroup, exists := ctx.Get("auto_group") if exists { logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup)) relayInfo.UsingGroup = autoGroup.(string) } // check user group special ratio userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) if ok { // user group special ratio groupRatioInfo.GroupSpecialRatio = userGroupRatio groupRatioInfo.GroupRatio = userGroupRatio groupRatioInfo.HasSpecialRatio = true } else { // normal group ratio groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup) } return groupRatioInfo } func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (types.PriceData, error) { if info == nil { return types.PriceData{}, fmt.Errorf("relay info is nil") } channelID := 0 if info.ChannelMeta != nil { channelID = info.ChannelId } groupRatioInfo := HandleGroupRatio(c, info) supplierID := resolveSupplierIDByChannel(info) modelPrice, usePrice := model.ResolveSupplierScopedFixedModelPrice(channelID, supplierID, info.OriginModelName) // 归属供应商的渠道:固定价以 supplier_* 独立表优先于用户分组价;非供应商渠道保留分组覆盖。 if supplierID <= 0 { if groupPrice, ok := ratio_setting.GetGroupModelPrice(info.UsingGroup, info.OriginModelName); ok { modelPrice = groupPrice usePrice = true } } channelVideoRatio, hasChannelVideoRatio := ratio_setting.GetChannelVideoRatio(channelID, info.OriginModelName) channelVideoCompletionRatio, hasChannelVideoCompletionRatio := ratio_setting.GetChannelVideoCompletionRatio(channelID, info.OriginModelName) // 提前获取成本折扣率、加价折扣率及全局倍率/固定价(新计费公式所需) chDisc := model.ResolveChannelPriceDiscountPercent(channelID) markupDisc := effectiveMarkupDiscountPercent(c, info, channelID, info.OriginModelName) globalRatio, _, _ := ratio_setting.GetModelRatio(info.OriginModelName) globalPrice, _ := ratio_setting.GetModelPrice(info.OriginModelName, false) // 全局子倍率(用于各类型加价部分的独立计算) globalCompletionRatio := ratio_setting.GetCompletionRatio(info.OriginModelName) globalCacheRatio, globalCacheRatioOK := ratio_setting.GetCacheRatio(info.OriginModelName) if !globalCacheRatioOK { globalCacheRatio = 1.0 } globalCreateCacheRatio, globalCreateCacheRatioOK := ratio_setting.GetCreateCacheRatio(info.OriginModelName) if !globalCreateCacheRatioOK { globalCreateCacheRatio = 1.25 } var preConsumedQuota int var modelRatio float64 var completionRatio float64 var cacheRatio float64 var imageRatio float64 var cacheCreationRatio float64 var cacheCreationRatio5m float64 var cacheCreationRatio1h float64 var audioRatio float64 var audioCompletionRatio float64 var videoRatio float64 var videoCompletionRatio float64 var freeModel bool if !usePrice { preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota) if meta.MaxTokens != 0 { preConsumedTokens += meta.MaxTokens } var success bool var matchName string modelRatio, success, matchName = model.ResolveSupplierScopedModelRatio(channelID, supplierID, info.OriginModelName) // 供应商自有渠道:输入倍率以独立表(及 Resolve 内平台渠道 Option 回退)为准,不被用户分组倍率覆盖。 if supplierID <= 0 { if groupModelRatio, ok := ratio_setting.GetGroupModelRatio(info.UsingGroup, info.OriginModelName); ok { modelRatio = groupModelRatio success = true } } if !success { acceptUnsetRatio := false if info.UserSetting.AcceptUnsetRatioModel { acceptUnsetRatio = true } if !acceptUnsetRatio { return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) } } // 输出/缓存/图音倍率:ResolveSupplierScoped* 内已为「供应商渠道表 > 供应商全局表 > 平台渠道 Option > 全局」; // 此处禁止再次用 channel_* Option 覆盖,否则会同模型下压过供应商独立表。 completionRatio = model.ResolveSupplierScopedCompletionRatio(channelID, supplierID, info.OriginModelName) cacheRatio, cacheCreationRatio = model.ResolveSupplierScopedCacheRatios(channelID, supplierID, info.OriginModelName) cacheCreationRatio5m = cacheCreationRatio // 固定1h和5min缓存写入价格的比例 cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier imageRatio, _ = model.ResolveSupplierScopedImageRatio(channelID, supplierID, info.OriginModelName) audioRatio = model.ResolveSupplierScopedAudioRatio(channelID, supplierID, info.OriginModelName) audioCompletionRatio = model.ResolveSupplierScopedAudioCompletionRatio(channelID, supplierID, info.OriginModelName) // 供应商表暂无 Video 字段:仍采用全局 + 平台渠道 Option(与旧逻辑一致)。 videoRatio = ratio_setting.GetVideoRatio(info.OriginModelName) videoCompletionRatio = ratio_setting.GetVideoCompletionRatio(info.OriginModelName) if hasChannelVideoRatio { videoRatio = channelVideoRatio } if hasChannelVideoCompletionRatio { videoCompletionRatio = channelVideoCompletionRatio } // 新公式:有效输入倍率 = 渠道倍率 * 成本折扣率% + 全局倍率 * 加价折扣率% effInputRate := model.EffectiveInputRate(modelRatio, globalRatio, chDisc, markupDisc) effInputRateWithGroup := effInputRate * groupRatioInfo.GroupRatio dPreConsumedTokens := decimal.NewFromInt(int64(preConsumedTokens)) if tier, ok := ratio_setting.ResolveModelTierRatio(channelID, info.OriginModelName); ok { dPreConsumedTokens = ratio_setting.ApplyTierSegmentsForType(dPreConsumedTokens, tier) } preConsumedQuota = int(dPreConsumedTokens.Mul(decimal.NewFromFloat(effInputRateWithGroup)).Round(0).IntPart()) } else { // 固定价格:渠道固定价 * 成本折扣率% + 全局固定价 * 加价折扣率% effModelPrice := model.EffectiveModelPrice(modelPrice, globalPrice, chDisc, markupDisc) if meta.ImagePriceRatio != 0 { effModelPrice = effModelPrice * meta.ImagePriceRatio modelPrice = modelPrice * meta.ImagePriceRatio // 保持 ModelPrice 字段与 imagePriceRatio 一致(供日志展示) } preConsumedQuota = int(effModelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) } // check if free model pre-consume is disabled if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { // if model price or ratio is 0, do not pre-consume quota if groupRatioInfo.GroupRatio == 0 { preConsumedQuota = 0 freeModel = true } else if usePrice { if modelPrice == 0 { preConsumedQuota = 0 freeModel = true } } else { if modelRatio == 0 { preConsumedQuota = 0 freeModel = true } } } chDiscCopy := chDisc priceData := types.PriceData{ FreeModel: freeModel, ModelPrice: modelPrice, ModelRatio: modelRatio, CompletionRatio: completionRatio, GroupRatioInfo: groupRatioInfo, UsePrice: usePrice, CacheRatio: cacheRatio, ImageRatio: imageRatio, AudioRatio: audioRatio, AudioCompletionRatio: audioCompletionRatio, VideoRatio: videoRatio, VideoCompletionRatio: videoCompletionRatio, CacheCreationRatio: cacheCreationRatio, CacheCreation5mRatio: cacheCreationRatio5m, CacheCreation1hRatio: cacheCreationRatio1h, ChannelPriceDiscount: &chDiscCopy, QuotaToPreConsume: preConsumedQuota, // 新计费公式字段 CostDiscountPercent: chDisc, MarkupDiscountPercent: markupDisc, GlobalModelRatio: globalRatio, GlobalModelPrice: globalPrice, GlobalCompletionRatio: globalCompletionRatio, GlobalCacheRatio: globalCacheRatio, GlobalCreateCacheRatio: globalCreateCacheRatio, } if common.DebugEnabled { println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting())) } info.PriceData = priceData return priceData, nil } // ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task) func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { if info == nil { return types.PriceData{}, fmt.Errorf("relay info is nil") } channelID := 0 if info.ChannelMeta != nil { channelID = info.ChannelId } groupRatioInfo := HandleGroupRatio(c, info) supplierID := resolveSupplierIDByChannel(info) modelPrice, success := model.ResolveSupplierScopedFixedModelPrice(channelID, supplierID, info.OriginModelName) if supplierID <= 0 { if groupPrice, ok := ratio_setting.GetGroupModelPrice(info.UsingGroup, info.OriginModelName); ok { modelPrice = groupPrice success = true } } // 如果没有配置价格,检查模型倍率配置 if !success { // 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用 defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] if ok { modelPrice = defaultPrice } else { // 没有配置倍率也不接受没配置,那就返回错误 _, ratioSuccess, matchName := model.ResolveSupplierScopedModelRatio(channelID, supplierID, info.OriginModelName) acceptUnsetRatio := false if info.UserSetting.AcceptUnsetRatioModel { acceptUnsetRatio = true } 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 } } // 新公式:固定价格 = 渠道固定价 * 成本折扣率% + 全局固定价 * 加价折扣率% chDisc := model.ResolveChannelPriceDiscountPercent(channelID) markupDisc := effectiveMarkupDiscountPercent(c, info, channelID, info.OriginModelName) globalPrice, _ := ratio_setting.GetModelPrice(info.OriginModelName, false) effModelPrice := model.EffectiveModelPrice(modelPrice, globalPrice, chDisc, markupDisc) quota := int(effModelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) // 免费模型检测(与 ModelPriceHelper 对齐) freeModel := false if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { quota = 0 freeModel = true } } chDiscCopy := chDisc priceData := types.PriceData{ FreeModel: freeModel, ModelPrice: modelPrice, Quota: quota, GroupRatioInfo: groupRatioInfo, ChannelPriceDiscount: &chDiscCopy, CostDiscountPercent: chDisc, MarkupDiscountPercent: markupDisc, GlobalModelRatio: 0, GlobalModelPrice: globalPrice, } return priceData, nil } // ============================================================================ // Video task pricing // ============================================================================ // // Video generation channels (OpenAI Sora /v1/videos, OpenAI-compatible video // gateway /v1/videos/generations, etc.) are submitted via the task framework // and historically only supported per-call pricing through ModelPriceHelperPerCall. // // ModelPriceHelperVideo extends that with optional token-based pricing using // VideoRatio / VideoCompletionRatio, so admins who prefer "$/1M token" semantics // can configure the per-second/per-resolution cost via ratios instead of a // flat per-video price. // // Selection rules (highest priority first), aligned with the ratio UI // 「按 token 计费 / 按视频计费」: // // 1. Token-based pricing when tryVideoTokenPriceData succeeds (VideoRatio / // resolution token_price rules + ModelRatio). Takes precedence over // per-call ModelPrice / VideoPrice so admins who choose per-token in the // console are not overridden by legacy fixed prices. // // 2. Per-resolution flat price per video (*_per_video tables), then // // 3. Any other per-call tier (supplier ModelPrice, group ModelPrice, VideoPrice, // ChannelVideoPrice) -> ModelPriceHelperPerCall (OtherRatios from adaptor). // // 4. Nothing matched -> ModelPriceHelperPerCall fallback (error or default). const ( // defaultVideoFPS is used when the request body does not pin an explicit // fps; 24 matches Seedance / Doubao / most consumer video generators. defaultVideoFPS = 24 // defaultVideoWidth / defaultVideoHeight are the fallback dimensions when // the request did not provide a "size" field; 720p portrait keeps quota // estimates conservative for the most common Seedance preset. defaultVideoWidth = 720 defaultVideoHeight = 1280 // defaultVideoDuration is used when neither metadata.duration, req.Seconds // nor req.Duration carry a positive value. defaultVideoDuration = 5 ) type videoBillingMode string const ( videoBillingModeTextToVideo videoBillingMode = "text_to_video" videoBillingModeImageToVideo videoBillingMode = "image_to_video" videoBillingModeVideoToVideo videoBillingMode = "video_to_video" ) type videoEstimateContext struct { Mode videoBillingMode InputTextTokens int Width int Height int FPS int DurationSec int } // ModelPriceHelperVideo computes the quota for video generation tasks. // Video billing only supports the new rule tables: // 1) per-second rules (ceil(seconds) * unit price) // 2) per-item rules (fixed price per generated video) // Legacy token-based / per-call fallback is intentionally disabled. func ModelPriceHelperVideo(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { if info == nil { return types.PriceData{}, fmt.Errorf("relay info is nil") } // 1) Per-second rules first (new mode): ceil(seconds) × unit price. if priceData, ok, err := tryVideoPerSecondRulesPriceData(c, info); err != nil { return types.PriceData{}, err } else if ok { return priceData, nil } // 2) Per-item rules. if priceData, ok, err := tryVideoPerVideoRulesPriceData(c, info); err != nil { return types.PriceData{}, err } else if ok { return priceData, nil } // 3) No video rules configured -> explicit "price not set" error. matchName := ratio_setting.FormatMatchingModelName(info.OriginModelName) if matchName == "" { matchName = info.OriginModelName } return types.PriceData{}, fmt.Errorf("视频模型 %s 未设置价格,请配置按视频秒收费或按视频条数收费规则;Video model %s price not set, please configure per-second or per-item video pricing rules", matchName, matchName) } // hasAnyPerCallVideoPrice reports whether any per-call price tier is set for // this model。与 ModelPriceHelperPerCall 对齐:优先 supplier_* 独立表再回退 Option。 func hasAnyPerCallVideoPrice(channelID, supplierID int, group, modelName string) bool { if _, ok := model.ResolveSupplierScopedFixedModelPrice(channelID, supplierID, modelName); ok { return true } if supplierID <= 0 { if _, ok := ratio_setting.GetGroupModelPrice(group, modelName); ok { return true } } // VideoPrice:专用按次价(与通用 ModelPrice 字段不同)。 if _, ok := ratio_setting.GetVideoPrice(modelName); ok { return true } if _, ok := ratio_setting.GetChannelVideoPrice(channelID, modelName); ok { return true } return false } // tryVideoTokenPriceData attempts to price the request using token ratios. // Returns (priceData, true, nil) on success; (zero, false, nil) when ratios // are not configured (caller should fall through); or (zero, false, err) on // hard failures. func tryVideoTokenPriceData(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, bool, error) { channelID := 0 if info.ChannelMeta != nil { channelID = info.ChannelId } supplierID := resolveSupplierIDByChannel(info) modelName := info.OriginModelName // 输入倍率:与 ModelPriceHelper 一致,供应商渠道走 ResolveSupplierScoped(独立表优先于渠道 Option)。 modelRatio, modelRatioOK, _ := model.ResolveSupplierScopedModelRatio(channelID, supplierID, modelName) if supplierID <= 0 { if r, ok := ratio_setting.GetGroupModelRatio(info.UsingGroup, modelName); ok { modelRatio = r modelRatioOK = true } } // Resolve videoRatio: channel > global. For legacy pricing without resolution // rules, an explicit map entry is required. When VideoPricingRules exist // (per-resolution token_price from the UI), that alone is enough signal even // if VideoRatio / ChannelVideoRatio were never set. videoRatio := ratio_setting.GetVideoRatio(modelName) hasVideoRatio := ratio_setting.ContainsVideoRatio(modelName) if r, ok := ratio_setting.GetChannelVideoRatio(channelID, modelName); ok { videoRatio = r hasVideoRatio = true } if !hasVideoRatio { if _, ok := resolveVideoPricingRules(channelID, modelName); ok { hasVideoRatio = true } } if !hasVideoRatio { return types.PriceData{}, false, nil } // Without a ModelRatio the entire formula collapses to 0; refuse. if !modelRatioOK || modelRatio <= 0 { return types.PriceData{}, false, nil } videoCompletionRatio := ratio_setting.GetVideoCompletionRatio(modelName) if r, ok := ratio_setting.GetChannelVideoCompletionRatio(channelID, modelName); ok { videoCompletionRatio = r } if videoCompletionRatio <= 0 { videoCompletionRatio = 1.0 } groupRatioInfo := HandleGroupRatio(c, info) estimateCtx := estimateVideoRequestContext(c) inputTextTokens := estimateCtx.InputTextTokens outputVideoTokens := 0 appliedVideoRatio := videoRatio appliedVideoCompletionRatio := videoCompletionRatio usedRulePricing := false if rules, ok := resolveVideoPricingRules(channelID, modelName); ok { if tokens, tokenPrice, ok := estimateVideoTokensWithRules(estimateCtx, rules); ok { outputVideoTokens = tokens appliedVideoRatio = tokenPrice appliedVideoCompletionRatio = 1.0 usedRulePricing = true } } if outputVideoTokens <= 0 { outputVideoTokens = estimateVideoOutputTokens(estimateCtx) } // Token-weighted quota (mirrors the audio formula in service.calculateAudioQuota). weightedTokens := float64(inputTextTokens) + float64(outputVideoTokens)*appliedVideoRatio*appliedVideoCompletionRatio rawQuota := weightedTokens * modelRatio * groupRatioInfo.GroupRatio // Free-model handling: align with ModelPriceHelper. freeModel := false if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 { rawQuota = 0 freeModel = true } } // 新公式(视频 token 计费):有效倍率 = 渠道倍率 * 成本折扣率% + 全局倍率 * 加价折扣率% chDisc := model.ResolveChannelPriceDiscountPercent(channelID) markupDisc := effectiveMarkupDiscountPercent(c, info, channelID, info.OriginModelName) globalRatioVideo, _, _ := ratio_setting.GetModelRatio(modelName) effRateVideo := model.EffectiveInputRate(modelRatio, globalRatioVideo, chDisc, markupDisc) // rawQuota 已按 modelRatio * groupRatio 计算,用 effRateVideo/modelRatio 修正(modelRatio>0 时) if modelRatio > 0 { rawQuota = rawQuota * effRateVideo / modelRatio } chDiscCopy := chDisc quota := int(math.Round(rawQuota)) // Floor non-zero results at 1 quota unit, matching calculateAudioQuota. if !freeModel && weightedTokens > 0 && quota <= 0 && modelRatio > 0 && groupRatioInfo.GroupRatio > 0 { quota = 1 } priceData := types.PriceData{ FreeModel: freeModel, ModelRatio: modelRatio, VideoRatio: appliedVideoRatio, VideoCompletionRatio: appliedVideoCompletionRatio, VideoOutputTokens: outputVideoTokens, VideoInputTextTokens: inputTextTokens, GroupRatioInfo: groupRatioInfo, Quota: quota, QuotaToPreConsume: quota, ChannelPriceDiscount: &chDiscCopy, CostDiscountPercent: chDisc, MarkupDiscountPercent: markupDisc, GlobalModelRatio: globalRatioVideo, // UsePrice = true tells relay_task to skip the OtherRatios multiplication // loop, since outputVideoTokens already encodes duration and resolution. UsePrice: true, } if common.DebugEnabled { branch := "legacy_ratio" if usedRulePricing { branch = "rule_based" } logger.LogDebug(c, fmt.Sprintf( "[video][token-pricing][%s] model=%s inputTextTokens=%d outputVideoTokens=%d modelRatio=%.4f videoRatio=%.4f videoCompletionRatio=%.4f groupRatio=%.4f -> quota=%d", branch, modelName, inputTextTokens, outputVideoTokens, modelRatio, appliedVideoRatio, appliedVideoCompletionRatio, groupRatioInfo.GroupRatio, quota, )) } return priceData, true, nil } func resolveVideoPricingRules(channelID int, modelName string) (ratio_setting.VideoPricingRules, bool) { if rules, ok := ratio_setting.GetChannelVideoPricingRules(channelID, modelName); ok { if hasUsableVideoPricingRules(rules) { return rules, true } } if rules, ok := ratio_setting.GetVideoPricingRules(modelName); ok { if hasUsableVideoPricingRules(rules) { return rules, true } } return ratio_setting.VideoPricingRules{}, false } func resolveVideoPerVideoPricingRules(channelID int, modelName string) (ratio_setting.VideoPricingRules, bool) { if rules, ok := ratio_setting.GetChannelVideoPricingRules(channelID, modelName); ok { if ratio_setting.HasUsableVideoPerVideoRules(rules) { return rules, true } } if rules, ok := ratio_setting.GetVideoPricingRules(modelName); ok { if ratio_setting.HasUsableVideoPerVideoRules(rules) { return rules, true } } return ratio_setting.VideoPricingRules{}, false } func resolveVideoPerSecondPricingRules(channelID int, modelName string) (ratio_setting.VideoPricingRules, bool) { if rules, ok := ratio_setting.GetChannelVideoPricingRules(channelID, modelName); ok { if ratio_setting.HasUsableVideoPerSecondRules(rules) { return rules, true } } if rules, ok := ratio_setting.GetVideoPricingRules(modelName); ok { if ratio_setting.HasUsableVideoPerSecondRules(rules) { return rules, true } } return ratio_setting.VideoPricingRules{}, false } func pickAudioPriceByResolution(ctx videoEstimateContext, hasAudio bool, rows []ratio_setting.VideoResolutionAudioPriceRule) (float64, bool) { if len(rows) == 0 { return 0, false } targetLong, targetShort := normalizeResolutionSides(ctx.Width, ctx.Height) targetRatio := targetResolutionRatio(ctx.Width, ctx.Height) bestIdx := -1 bestPixels := int(^uint(0) >> 1) fallbackIdx := -1 fallbackPixels := 0 for i := range rows { r := rows[i] if r.Price <= 0 || r.HasAudio != hasAudio { continue } ruleW, ruleH, ok := parseResolutionFlexibleForRatio(r.Resolution, targetRatio) if !ok { continue } rulePixels := ruleW * ruleH if rulePixels <= 0 { continue } ruleLong, ruleShort := normalizeResolutionSides(ruleW, ruleH) if ruleLong >= targetLong && ruleShort >= targetShort { if rulePixels < bestPixels { bestPixels = rulePixels bestIdx = i } continue } if rulePixels > fallbackPixels { fallbackPixels = rulePixels fallbackIdx = i } } if bestIdx < 0 { bestIdx = fallbackIdx } if bestIdx < 0 { return 0, false } return rows[bestIdx].Price, true } func normalizeResolutionSides(width, height int) (longSide, shortSide int) { if width >= height { return width, height } return height, width } func targetResolutionRatio(width, height int) float64 { longSide, shortSide := normalizeResolutionSides(width, height) if longSide <= 0 || shortSide <= 0 { return 16.0 / 9.0 } ratio := float64(longSide) / float64(shortSide) candidates := []float64{ 1.0, 4.0 / 3.0, 16.0 / 9.0, 21.0 / 9.0, } best := candidates[0] bestDiff := math.Abs(ratio - best) for _, candidate := range candidates[1:] { if diff := math.Abs(ratio - candidate); diff < bestDiff { best = candidate bestDiff = diff } } return best } func parseResolutionFlexibleForRatio(s string, ratio float64) (int, int, bool) { raw := strings.ToLower(strings.TrimSpace(s)) if raw == "" { return 0, 0, false } if w, h, ok := parseResolution(raw); ok { return w, h, true } shortSide := 0 switch raw { case "480p": shortSide = 480 case "540p": shortSide = 540 case "720p": shortSide = 720 case "1080p": shortSide = 1080 case "2k": shortSide = 1440 case "4k": shortSide = 2160 default: return 0, 0, false } longSide := int(math.Ceil(float64(shortSide) * ratio)) return longSide, shortSide, true } func parseResolutionFlexible(s string) (int, int, bool) { raw := strings.ToLower(strings.TrimSpace(s)) if raw == "" { return 0, 0, false } if w, h, ok := parseResolution(raw); ok { return w, h, true } switch raw { case "480p": return 854, 480, true case "540p": return 960, 540, true case "720p": return 1280, 720, true case "1080p": return 1920, 1080, true case "2k": return 2560, 1440, true case "4k": return 3840, 2160, true default: return 0, 0, false } } func requestHasAudio(c *gin.Context) bool { req, err := relaycommon.GetTaskRequest(c) if err != nil || req.Metadata == nil { return false } if v, ok := req.Metadata["has_audio"]; ok { switch x := v.(type) { case bool: return x case string: return strings.EqualFold(strings.TrimSpace(x), "true") } } if v, ok := req.Metadata["generate_audio"]; ok { switch x := v.(type) { case bool: return x case string: return strings.EqualFold(strings.TrimSpace(x), "true") } } return false } func tryVideoPerSecondRulesPriceData(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, bool, error) { channelID := 0 if info.ChannelMeta != nil { channelID = info.ChannelId } rules, ok := resolveVideoPerSecondPricingRules(channelID, info.OriginModelName) if !ok { return types.PriceData{}, false, nil } estimateCtx := estimateVideoRequestContext(c) hasAudio := requestHasAudio(c) seconds := estimateCtx.DurationSec if seconds <= 0 { seconds = defaultVideoDuration } seconds = int(math.Ceil(float64(seconds))) var pricePerSecond float64 switch estimateCtx.Mode { case videoBillingModeImageToVideo: pricePerSecond, ok = pickAudioPriceByResolution(estimateCtx, hasAudio, rules.ImageToVideoPerSecond) case videoBillingModeVideoToVideo: pricePerSecond, ok = pickAudioPriceByResolution(estimateCtx, hasAudio, rules.VideoToVideoPerSecond) default: pricePerSecond, ok = pickAudioPriceByResolution(estimateCtx, hasAudio, rules.TextToVideoPerSecond) } if !ok || pricePerSecond <= 0 { return types.PriceData{}, false, nil } groupRatioInfo := HandleGroupRatio(c, info) chDiscVPS := model.ResolveChannelPriceDiscountPercent(channelID) markupDiscVPS := effectiveMarkupDiscountPercent(c, info, channelID, info.OriginModelName) globalPerSec := globalVideoPerSecondUSDForRelay(info.OriginModelName, string(estimateCtx.Mode), estimateCtx.Width, estimateCtx.Height, hasAudio) effPricePerSecond := model.EffectiveRuleUnitPrice(pricePerSecond, globalPerSec, chDiscVPS, markupDiscVPS) rawQuota := float64(seconds) * effPricePerSecond * common.QuotaPerUnit * groupRatioInfo.GroupRatio chDiscCopyVPS := chDiscVPS quota := int(math.Round(rawQuota)) if quota <= 0 && rawQuota > 0 { quota = 1 } pd := types.PriceData{ ModelPrice: 0, ModelRatio: 0, GroupRatioInfo: groupRatioInfo, UsePrice: true, Quota: quota, QuotaToPreConsume: quota, ChannelPriceDiscount: &chDiscCopyVPS, CostDiscountPercent: chDiscVPS, MarkupDiscountPercent: markupDiscVPS, } pd.AddOtherRatio("seconds", float64(seconds)) if hasAudio { pd.AddOtherRatio("has_audio", 1) } return pd, true, nil } // matchPerVideoRulesByPixels picks the resolution row whose WxH is closest to // the request (same relative pixel error heuristic as token resolution rules). func matchPerVideoRulesByPixels(ctx videoEstimateContext, rules []ratio_setting.VideoResolutionPerVideoRule) (float64, bool) { if len(rules) == 0 || ctx.Width <= 0 || ctx.Height <= 0 { return 0, false } bestIdx := -1 targetPixels := ctx.Width * ctx.Height minDiffRatio := math.MaxFloat64 for i, rule := range rules { if rule.VideoPrice <= 0 { continue } ruleW, ruleH, ok := parseResolution(rule.Resolution) if !ok { continue } rulePixels := ruleW * ruleH if rulePixels <= 0 { continue } diffRatio := math.Abs(float64(targetPixels-rulePixels)) / float64(rulePixels) if diffRatio < minDiffRatio { minDiffRatio = diffRatio bestIdx = i } } if bestIdx < 0 { return 0, false } return rules[bestIdx].VideoPrice, true } func matchFlatPerVideoUSDRules(ctx videoEstimateContext, rules ratio_setting.VideoPricingRules) (float64, bool) { switch ctx.Mode { case videoBillingModeImageToVideo: return matchPerVideoRulesByPixels(ctx, rules.ImageToVideoPerVideo) case videoBillingModeVideoToVideo: var sum float64 n := 0 if u, ok := matchPerVideoRulesByPixels(ctx, rules.VideoToVideoInputPerVideo); ok { sum += u n++ } if u, ok := matchPerVideoRulesByPixels(ctx, rules.VideoToVideoOutputPerVideo); ok { sum += u n++ } if n > 0 { return sum, true } return 0, false default: return matchPerVideoRulesByPixels(ctx, rules.TextToVideoPerVideo) } } func tryVideoPerVideoRulesPriceData(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, bool, error) { channelID := 0 if info.ChannelMeta != nil { channelID = info.ChannelId } modelName := info.OriginModelName rules, ok := resolveVideoPerVideoPricingRules(channelID, modelName) if !ok { return types.PriceData{}, false, nil } estimateCtx := estimateVideoRequestContext(c) hasAudio := requestHasAudio(c) usd, okPrice := matchFlatPerVideoUSDRules(estimateCtx, rules) if !okPrice || usd <= 0 { // New per-item table first; fallback to legacy *_per_video. switch estimateCtx.Mode { case videoBillingModeImageToVideo: usd, okPrice = pickAudioPriceByResolution(estimateCtx, hasAudio, rules.ImageToVideoPerItem) case videoBillingModeVideoToVideo: usd, okPrice = pickAudioPriceByResolution(estimateCtx, hasAudio, rules.VideoToVideoPerItem) default: usd, okPrice = pickAudioPriceByResolution(estimateCtx, hasAudio, rules.TextToVideoPerItem) } } if !okPrice || usd <= 0 { return types.PriceData{}, false, nil } groupRatioInfo := HandleGroupRatio(c, info) rawQuota := usd * common.QuotaPerUnit * groupRatioInfo.GroupRatio freeModel := false if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 { rawQuota = 0 freeModel = true } } chDiscVPV := model.ResolveChannelPriceDiscountPercent(channelID) markupDiscVPV := effectiveMarkupDiscountPercent(c, info, channelID, info.OriginModelName) globalUsd := globalVideoPerVideoUSDForRelay(modelName, string(estimateCtx.Mode), estimateCtx.Width, estimateCtx.Height, hasAudio) effUsd := model.EffectiveRuleUnitPrice(usd, globalUsd, chDiscVPV, markupDiscVPV) rawQuota = effUsd * common.QuotaPerUnit * groupRatioInfo.GroupRatio chDiscCopyVPV := chDiscVPV quota := int(math.Round(rawQuota)) if !freeModel && quota <= 0 && rawQuota > 0 && groupRatioInfo.GroupRatio > 0 { quota = 1 } priceData := types.PriceData{ FreeModel: freeModel, ModelPrice: 0, ModelRatio: 0, GroupRatioInfo: groupRatioInfo, UsePrice: true, Quota: quota, QuotaToPreConsume: quota, ChannelPriceDiscount: &chDiscCopyVPV, CostDiscountPercent: chDiscVPV, MarkupDiscountPercent: markupDiscVPV, GlobalModelPrice: globalUsd, } if common.DebugEnabled { logger.LogDebug(c, fmt.Sprintf( "[video][per-video-rules] model=%s mode=%s w=%d h=%d usd=%.6f groupRatio=%.4f -> quota=%d", modelName, estimateCtx.Mode, estimateCtx.Width, estimateCtx.Height, usd, groupRatioInfo.GroupRatio, quota, )) } return priceData, true, nil } func hasUsableVideoPricingRules(rules ratio_setting.VideoPricingRules) bool { if len(rules.TextToVideo) > 0 { return true } if len(rules.ImageToVideoRules) > 0 { return true } if len(rules.VideoToVideoInput) > 0 { return true } if len(rules.VideoToVideoOutput) > 0 { return true } if len(rules.VideoToVideo) > 0 { return true } return rules.ImageToVideo != nil && rules.ImageToVideo.TokenPrice > 0 } // estimateVideoTokens derives (inputTextTokens, outputVideoTokens) from the // parsed TaskSubmitReq currently stored in the gin context. // // outputVideoTokens follows the formula widely used by Volcano Engine / // Doubao docs: // // tokens = duration * width * height * fps / 1024 // // inputTextTokens use a conservative "1 token per 4 prompt characters" heuristic // that does not require pulling in the heavy tokenizer dependency for every // task submission. This is intentionally a coarse estimate; real-world video // pricing is dominated by the output term (it scales with W*H*fps), so prompt // inaccuracy is negligible. func estimateVideoRequestContext(c *gin.Context) videoEstimateContext { ctx := videoEstimateContext{ Mode: videoBillingModeTextToVideo, Width: defaultVideoWidth, Height: defaultVideoHeight, FPS: defaultVideoFPS, DurationSec: defaultVideoDuration, } req, err := relaycommon.GetTaskRequest(c) if err != nil { return ctx } ctx.Mode = detectVideoBillingMode(&req) ctx.DurationSec = resolveVideoDuration(&req) ctx.Width, ctx.Height = resolveVideoDimensions(&req) ctx.FPS = resolveVideoFPS(&req) if prompt := strings.TrimSpace(req.GetPrompt()); prompt != "" { // 1 token per ~4 characters is the well-known OpenAI rule of thumb // for English; for CJK-heavy prompts it overestimates slightly, which // is acceptable here (we err on charging more rather than less). ctx.InputTextTokens = int(math.Ceil(float64(len([]rune(prompt))) / 4.0)) } return ctx } func estimateVideoOutputTokens(ctx videoEstimateContext) int { return videoOutputTokens(ctx.DurationSec, ctx.Width, ctx.Height, ctx.FPS) } func detectVideoBillingMode(req *relaycommon.TaskSubmitReq) videoBillingMode { if req == nil { return videoBillingModeTextToVideo } if strings.TrimSpace(req.InputReference) != "" { return videoBillingModeVideoToVideo } if strings.TrimSpace(req.Image) != "" { return videoBillingModeImageToVideo } for _, img := range req.Images { if strings.TrimSpace(img) != "" { return videoBillingModeImageToVideo } } return videoBillingModeTextToVideo } func estimateVideoTokensWithRules(ctx videoEstimateContext, rules ratio_setting.VideoPricingRules) (tokens int, tokenPrice float64, ok bool) { switch ctx.Mode { case videoBillingModeImageToVideo: if len(rules.ImageToVideoRules) > 0 { return estimateImageByResolutionRules(ctx, rules.ImageToVideoRules, rules.SimilarityThreshold) } if rules.ImageToVideo != nil && rules.ImageToVideo.TokenPrice > 0 { compression := rules.ImageToVideo.PixelCompression if compression <= 0 { compression = 1024 } raw := float64(ctx.Width*ctx.Height) / compression if raw < 1 { raw = 1 } return int(math.Ceil(raw)), rules.ImageToVideo.TokenPrice, true } return estimateByResolutionRules(ctx, rules.TextToVideo, rules.SimilarityThreshold) case videoBillingModeVideoToVideo: if len(rules.VideoToVideoInput) > 0 || len(rules.VideoToVideoOutput) > 0 { inTokens, inPrice, okIn := estimateByResolutionRules(ctx, rules.VideoToVideoInput, rules.SimilarityThreshold) outTokens, outPrice, okOut := estimateByResolutionRules(ctx, rules.VideoToVideoOutput, rules.SimilarityThreshold) if okIn || okOut { weighted := 0.0 if okIn { weighted += float64(inTokens) * inPrice } if okOut { weighted += float64(outTokens) * outPrice } if weighted > 0 { return int(math.Ceil(weighted)), 1.0, true } } } if tokens, tokenPrice, ok := estimateByResolutionRules(ctx, rules.VideoToVideo, rules.SimilarityThreshold); ok { return tokens, tokenPrice, ok } return estimateByResolutionRules(ctx, rules.TextToVideo, rules.SimilarityThreshold) default: return estimateByResolutionRules(ctx, rules.TextToVideo, rules.SimilarityThreshold) } } func estimateImageByResolutionRules(ctx videoEstimateContext, rules []ratio_setting.VideoResolutionPriceRule, threshold float64) (tokens int, tokenPrice float64, ok bool) { if len(rules) == 0 { return 0, 0, false } bestRule := rules[0] targetPixels := ctx.Width * ctx.Height minDiffRatio := math.MaxFloat64 for _, rule := range rules { if rule.TokenPrice <= 0 { continue } ruleW, ruleH, ok := parseResolution(rule.Resolution) if !ok { continue } rulePixels := ruleW * ruleH if rulePixels <= 0 { continue } diffRatio := math.Abs(float64(targetPixels-rulePixels)) / float64(rulePixels) if diffRatio < minDiffRatio { minDiffRatio = diffRatio bestRule = rule } } if bestRule.TokenPrice <= 0 { return 0, 0, false } if threshold <= 0 { threshold = 0.35 } compression := bestRule.PixelCompression if compression <= 0 { compression = 1024 } raw := float64(ctx.Width*ctx.Height) / compression if raw < 1 { raw = 1 } return int(math.Ceil(raw)), bestRule.TokenPrice, true } func estimateByResolutionRules(ctx videoEstimateContext, rules []ratio_setting.VideoResolutionPriceRule, threshold float64) (tokens int, tokenPrice float64, ok bool) { if len(rules) == 0 { return 0, 0, false } bestRule := rules[0] targetPixels := ctx.Width * ctx.Height minDiffRatio := math.MaxFloat64 for _, rule := range rules { if rule.TokenPrice <= 0 { continue } ruleW, ruleH, ok := parseResolution(rule.Resolution) if !ok { continue } rulePixels := ruleW * ruleH if rulePixels <= 0 { continue } diffRatio := math.Abs(float64(targetPixels-rulePixels)) / float64(rulePixels) if diffRatio < minDiffRatio { minDiffRatio = diffRatio bestRule = rule } } if bestRule.TokenPrice <= 0 { return 0, 0, false } if threshold <= 0 { threshold = 0.35 } compression := bestRule.PixelCompression if compression <= 0 { compression = 1024 } _ = minDiffRatio > threshold // 超出阈值时仍按实际分辨率计费,不再强行套固定档位像素。 raw := float64(ctx.Width*ctx.Height*ctx.FPS*ctx.DurationSec) / compression if raw < 1 { raw = 1 } return int(math.Ceil(raw)), bestRule.TokenPrice, true } func parseResolution(size string) (width, height int, ok bool) { parts := strings.Split(strings.ToLower(strings.TrimSpace(size)), "x") if len(parts) != 2 { return 0, 0, false } w, err := strconv.Atoi(strings.TrimSpace(parts[0])) if err != nil || w <= 0 { return 0, 0, false } h, err := strconv.Atoi(strings.TrimSpace(parts[1])) if err != nil || h <= 0 { return 0, 0, false } return w, h, true } // videoOutputTokens is the canonical Volcano-style output-token formula. // All inputs are positive integers; the result is rounded up to the nearest // token to avoid silently zeroing out very small clips. func videoOutputTokens(durationSec, width, height, fps int) int { if durationSec <= 0 || width <= 0 || height <= 0 || fps <= 0 { return 0 } raw := float64(durationSec) * float64(width) * float64(height) * float64(fps) / 1024.0 if raw < 1 { return 1 } return int(math.Ceil(raw)) } // resolveVideoDuration prefers metadata.duration (most authoritative, allows // caller to override) > Seconds > Duration > defaultVideoDuration. func resolveVideoDuration(req *relaycommon.TaskSubmitReq) int { if req.Metadata != nil { if v, ok := req.Metadata["duration"]; ok { if d := coerceToPositiveInt(v); d > 0 { return d } } } if s := strings.TrimSpace(req.Seconds); s != "" { if d, err := strconv.Atoi(s); err == nil && d > 0 { return d } if f, err := strconv.ParseFloat(s, 64); err == nil && f > 0 { return int(math.Ceil(f)) } } if req.Duration > 0 { return req.Duration } return defaultVideoDuration } // resolveVideoDimensions parses req.Size ("WIDTHxHEIGHT") or falls back to // metadata.width/metadata.height; defaults at the end keep quota non-zero. func resolveVideoDimensions(req *relaycommon.TaskSubmitReq) (width, height int) { if size := strings.TrimSpace(req.Size); size != "" { parts := strings.Split(strings.ToLower(size), "x") if len(parts) == 2 { if w, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && w > 0 { if h, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && h > 0 { return w, h } } } } if req.Metadata != nil { w := coerceToPositiveInt(req.Metadata["width"]) h := coerceToPositiveInt(req.Metadata["height"]) if w > 0 && h > 0 { return w, h } } return defaultVideoWidth, defaultVideoHeight } // resolveVideoFPS uses metadata.fps when present; otherwise the safe default. func resolveVideoFPS(req *relaycommon.TaskSubmitReq) int { if req.Metadata != nil { if v := coerceToPositiveInt(req.Metadata["fps"]); v > 0 { return v } } return defaultVideoFPS } // coerceToPositiveInt turns common JSON-decoded numeric forms (float64, int, // int64, json.Number, numeric strings) into a positive int, or 0 if absent // / non-positive / unparseable. func coerceToPositiveInt(v any) int { switch x := v.(type) { case nil: return 0 case int: if x > 0 { return x } case int64: if x > 0 { return int(x) } case float64: if x > 0 { return int(math.Ceil(x)) } case string: if d, err := strconv.Atoi(strings.TrimSpace(x)); err == nil && d > 0 { return d } if f, err := strconv.ParseFloat(strings.TrimSpace(x), 64); err == nil && f > 0 { return int(math.Ceil(f)) } } return 0 } func ContainPriceOrRatio(modelName string) bool { _, ok := ratio_setting.GetModelPrice(modelName, false) if ok { return true } _, ok, _ = ratio_setting.GetModelRatio(modelName) if ok { return true } return false }