1281 lines
42 KiB
Go
1281 lines
42 KiB
Go
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
|
||
}
|