chore(git): 合并视频时长线性计费修复
ci / verify (pull_request) Successful in 13m41s

This commit is contained in:
chengcheng
2026-07-21 13:34:52 +08:00
8 changed files with 178 additions and 24 deletions
+15 -7
View File
@@ -10,6 +10,8 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const videoBillingUnitSeconds = 5
type EstimateResult struct {
Items []any `json:"items"`
Resolver string `json:"resolver"`
@@ -135,7 +137,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
baseKey = "videoBase"
duration, durationSource := billingDurationSeconds(body, response)
audioEnabled, audioSource := billingAudioEnabled(body, response)
durationUnits := math.Max(1, math.Ceil(duration/5))
durationUnits := videoDurationUnits(duration)
amount := float64(count) *
durationUnits *
resourcePrice(config, resource, baseKey, "basePrice") *
@@ -144,7 +146,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
resourceWeight(config, resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body))) *
resourceWeight(config, resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body, audioEnabled))) *
discount
return []any{billingLineWithDetails(candidate, resource, unit, count*int(durationUnits), roundPrice(amount), discount, simulated, map[string]any{
return []any{billingLineWithDetails(candidate, resource, unit, videoDurationQuantity(duration, count), roundPrice(amount), discount, simulated, map[string]any{
"count": count,
"audio": audioEnabled,
"audioSource": audioSource,
@@ -416,6 +418,16 @@ func weightValueAliases(key string, name string) []string {
}
}
func videoDurationUnits(durationSeconds float64) float64 {
return videoDurationQuantity(durationSeconds, 1)
}
func videoDurationQuantity(durationSeconds float64, count int) float64 {
const durationPrecision = 1_000_000_000
scaledDuration := math.Round(durationSeconds * durationPrecision)
return scaledDuration * float64(count) / (durationPrecision * videoBillingUnitSeconds)
}
func requestOutputCount(body map[string]any) int {
for _, key := range []string{"n", "count", "batch_size", "batchSize"} {
if value := int(math.Ceil(floatFromAny(body[key]))); value > 0 {
@@ -476,11 +488,7 @@ func generatedVideoDurationSeconds(result map[string]any) (float64, bool) {
if duration <= 0 {
continue
}
rounded := math.Round(duration)
if rounded <= 0 {
rounded = 1
}
return rounded, true
return duration, true
}
return 0, false
}
+7 -7
View File
@@ -38,7 +38,7 @@ func TestImageBillingEstimateUsesCountResolutionAndQuality(t *testing.T) {
}
}
func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T) {
func TestVideoBillingEstimateProratesFiveSecondUnitsAndDynamicWeights(t *testing.T) {
service := &Service{}
candidate := store.RuntimeModelCandidate{
ModelName: "video-model",
@@ -67,13 +67,13 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T)
}, candidate, clients.Response{}, true)
line := firstBillingLine(t, items)
if got, want := floatFromAny(line["amount"]), 1620.0; got != want {
if got, want := floatFromAny(line["amount"]), 1296.0; got != want {
t.Fatalf("video estimated amount = %v, want %v", got, want)
}
if got, want := floatFromAny(line["durationUnitCount"]), 3.0; got != want {
if got, want := floatFromAny(line["durationUnitCount"]), 2.4; got != want {
t.Fatalf("video duration units = %v, want %v", got, want)
}
if got, want := line["quantity"], 3; got != want {
if got, want := floatFromAny(line["quantity"]), 2.4; got != want {
t.Fatalf("video quantity = %v, want %v", got, want)
}
if got, want := line["durationSource"], "preprocessed_request"; got != want {
@@ -172,13 +172,13 @@ func TestVideoBillingPrefersGeneratedDuration(t *testing.T) {
}, false)
line := firstBillingLine(t, items)
if got, want := floatFromAny(line["durationSeconds"]), 7.0; got != want {
if got, want := floatFromAny(line["durationSeconds"]), 6.6; got != want {
t.Fatalf("video generated duration = %v, want %v", got, want)
}
if got, want := floatFromAny(line["durationUnitCount"]), 2.0; got != want {
if got, want := floatFromAny(line["durationUnitCount"]), 1.32; got != want {
t.Fatalf("video generated duration units = %v, want %v", got, want)
}
if got, want := floatFromAny(line["amount"]), 200.0; got != want {
if got, want := floatFromAny(line["amount"]), 132.0; got != want {
t.Fatalf("video generated duration amount = %v, want %v", got, want)
}
if got, want := line["durationSource"], "generated_video"; got != want {
+29 -4
View File
@@ -184,6 +184,22 @@ func multiplyFixedAmountRatio(amount fixedAmount, numerator int, denominator int
return fixedAmountFromBigInt(roundBigIntRatio(product, big.NewInt(int64(denominator))))
}
func multiplyFixedProductRatio(base fixedAmount, integerFactors []int, fixedFactors []fixedAmount, denominator int) (fixedAmount, error) {
if denominator == 0 {
return 0, fmt.Errorf("division by zero")
}
product := big.NewInt(int64(base))
for _, factor := range integerFactors {
product.Mul(product, big.NewInt(int64(factor)))
}
divisor := big.NewInt(int64(denominator))
for _, factor := range fixedFactors {
product.Mul(product, big.NewInt(int64(factor)))
divisor.Mul(divisor, big.NewInt(fixedScale))
}
return fixedAmountFromBigInt(roundBigIntRatio(product, divisor))
}
func fixedAmountFromBigInt(value *big.Int) (fixedAmount, error) {
if value == nil || !value.IsInt64() {
return 0, errFixedAmountOverflow
@@ -649,7 +665,11 @@ func (s *Service) billingsWithResolvedPricingV2(
baseKey = "videoBase"
duration, durationSource := billingDurationSeconds(body, response)
audioEnabled, audioSource := billingAudioEnabled(body, response)
durationUnits := int(math.Max(1, math.Ceil(duration/5)))
durationUnits := videoDurationUnits(duration)
durationFixed, durationErr := fixedAmountFromAny(duration)
if durationErr != nil {
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, durationErr)
}
price, priceErr := pricing.requiredPrice(resource, baseKey, "basePrice")
if priceErr != nil {
return nil, 0, resolvedPricing{}, priceErr
@@ -670,11 +690,16 @@ func (s *Service) billingsWithResolvedPricingV2(
if weightErr != nil {
return nil, 0, resolvedPricing{}, weightErr
}
amount, calculationErr := pricing.calculate(resource, price, []int{count, durationUnits}, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount)
amount, calculationErr := multiplyFixedProductRatio(
price,
[]int{count},
[]fixedAmount{durationFixed, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount},
videoBillingUnitSeconds,
)
if calculationErr != nil {
return nil, 0, resolvedPricing{}, calculationErr
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, calculationErr)
}
item := buildLine(resource, unit, count*durationUnits, amount, map[string]any{
item := buildLine(resource, unit, videoDurationQuantity(duration, count), amount, map[string]any{
"count": count, "audio": audioEnabled, "audioSource": audioSource,
"durationSeconds": duration, "durationSource": durationSource,
"durationUnit": "5s", "durationUnitCount": durationUnits,
+107
View File
@@ -1,10 +1,12 @@
package runner
import (
"context"
"errors"
"math"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
@@ -39,6 +41,9 @@ func TestFixedAmountOperationsRejectOverflow(t *testing.T) {
if _, err := pricing.calculate("image", maximum, []int{2}); !isPricingUnavailable(err) {
t.Fatalf("pricing overflow should be unavailable: %v", err)
}
if _, err := multiplyFixedProductRatio(maximum, []int{2}, nil, 1); !errors.Is(err, errFixedAmountOverflow) {
t.Fatalf("product ratio overflow error=%v", err)
}
}
func TestEstimatedOutputTokensUsesAliasesAndCapabilityFallback(t *testing.T) {
@@ -138,6 +143,108 @@ func TestPricingWeightsUseFixedPrecisionAndRejectInvalidValues(t *testing.T) {
}
}
func TestVideoBillingV2ProratesFiveSecondPriceByActualDuration(t *testing.T) {
service := &Service{}
candidate := store.RuntimeModelCandidate{ModelName: "video-model"}
pricing := resolvedPricing{
Config: map[string]any{
"video": map[string]any{
"basePrice": 100,
"dynamicWeight": map[string]any{
"audioWeights": map[string]any{"true": 2},
},
},
},
Currency: "resource",
}
tests := []struct {
name string
duration float64
wantUnits float64
wantAmount float64
}{
{name: "three seconds uses zero point six units", duration: 3, wantUnits: 0.6, wantAmount: 120},
{name: "five seconds uses one unit", duration: 5, wantUnits: 1, wantAmount: 200},
{name: "six seconds uses one point two units", duration: 6, wantUnits: 1.2, wantAmount: 240},
{name: "fractional seconds retain fixed amount precision", duration: 6.000000001, wantUnits: 1.2000000002, wantAmount: 240.00000004},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
items, total, _, err := service.billingsWithResolvedPricingV2(
context.Background(), nil, "videos.generations",
map[string]any{"duration": test.duration, "audio": true},
candidate, clients.Response{}, true, pricing,
)
if err != nil {
t.Fatalf("bill video: %v", err)
}
line := firstBillingLine(t, items)
if got := total.Float64(); math.Abs(got-test.wantAmount) > 1e-9 {
t.Fatalf("total amount=%v, want %v", got, test.wantAmount)
}
if got := floatFromAny(line["amount"]); math.Abs(got-test.wantAmount) > 1e-9 {
t.Fatalf("line amount=%v, want %v", got, test.wantAmount)
}
if got := floatFromAny(line["quantity"]); math.Abs(got-test.wantUnits) > 1e-12 {
t.Fatalf("quantity=%v, want %v", got, test.wantUnits)
}
if got := floatFromAny(line["durationUnitCount"]); math.Abs(got-test.wantUnits) > 1e-12 {
t.Fatalf("duration units=%v, want %v", got, test.wantUnits)
}
})
}
}
func TestVideoBillingV2RoundsOnlyAfterApplyingDurationCountAndWeights(t *testing.T) {
service := &Service{}
candidate := store.RuntimeModelCandidate{ModelName: "video-model"}
tests := []struct {
name string
body map[string]any
pricing resolvedPricing
wantAmount string
}{
{
name: "count preserves a sub-nano duration share",
body: map[string]any{"duration": 1, "count": 5},
pricing: resolvedPricing{Config: map[string]any{
"video": map[string]any{"basePrice": "0.000000001"},
}},
wantAmount: "0.000000001",
},
{
name: "weight does not amplify a rounded duration share",
body: map[string]any{"duration": 3, "audio": true},
pricing: resolvedPricing{Config: map[string]any{
"video": map[string]any{
"basePrice": "0.000000001",
"dynamicWeight": map[string]any{
"audioWeights": map[string]any{"true": 2},
},
},
}},
wantAmount: "0.000000001",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, total, _, err := service.billingsWithResolvedPricingV2(
context.Background(), nil, "videos.generations", test.body,
candidate, clients.Response{}, true, test.pricing,
)
if err != nil {
t.Fatalf("bill video: %v", err)
}
if got := total.String(); got != test.wantAmount {
t.Fatalf("total amount=%s, want %s", got, test.wantAmount)
}
})
}
}
func mustFixedAmount(t *testing.T, value string) fixedAmount {
t.Helper()
amount, err := parseFixedAmount(value)
@@ -166,16 +166,16 @@ The response has this shape:
"platformModelId": "<platform-model-id>",
"resourceType": "video",
"unit": "5s_video",
"quantity": 3,
"quantity": 2.4,
"amount": 12.5,
"currency": "resource",
"discountFactor": 0.8,
"simulated": true,
"durationSeconds": 12,
"durationUnitCount": 3
"durationUnitCount": 2.4
}
],
"resolver": "effective-pricing-v1",
"resolver": "effective-pricing-v2",
"totalAmount": 12.5,
"currency": "resource"
}
@@ -197,7 +197,7 @@ Useful calculation checks:
text input = input tokens / 1000 × input price × discount
text output = output tokens / 1000 × output price × discount
image = count × base price × quality/size/resolution weights × discount
video = count × ceil(duration seconds / 5) × base price × applicable weights × discount
video = count × (duration seconds / 5) × base price × applicable weights × discount
speech = Unicode character count × audio price × discount
```
@@ -0,0 +1,14 @@
UPDATE model_pricing_rules
SET formula_config = jsonb_set(
COALESCE(formula_config, '{}'::jsonb),
'{formula}',
to_jsonb(replace(
formula_config->>'formula',
'ceil(duration_seconds / 5)',
'(duration_seconds / 5)'
)),
true
),
updated_at = now()
WHERE resource_type = 'video'
AND strpos(formula_config->>'formula', 'ceil(duration_seconds / 5)') > 0;
@@ -56,7 +56,7 @@ const modeDefinitions: ModeDefinition[] = [
formula: '扣费 = 基础单价 × 生成时长单位 × 数量 × 分辨率、音频、参考视频、音色等计费参数。',
match: (rule) => rule.resourceType === 'video',
templates: (currency) => [
createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', {
createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * (duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', {
resolutionWeights: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 },
audioWeights: { true: 2, false: 1 },
referenceVideoWeights: { true: 1.5, false: 1 },
+1 -1
View File
@@ -1430,7 +1430,7 @@ effective price = rule price(request dimensions) * platform/model discount * use
- 基础单位建议为 `5s``second`,与原 provider 配置保持可映射。
- 动态权重至少支持:时长、分辨率、是否包含音频、是否使用参考视频、是否指定声音/音色、生成数量。
- 分辨率示例:`480p``720p``1080p``2160p`
- 公式示例:`count * ceil(durationSeconds / unitSeconds) * basePrice * resolutionWeight * audioWeight * referenceWeight`
- 公式示例:`count * (durationSeconds / unitSeconds) * basePrice * resolutionWeight * audioWeight * referenceWeight`
- 规则维度:`durationSeconds``resolution``count``hasAudio``hasReferenceVideo``hasReferenceImage``voice`
音频、音乐、数字人、3D 模型: