fix(billing): 视频时长按实际秒数线性计费
将五秒基础价按 duration / 5 比例结算,保留 provider 返回的小数时长,避免六秒视频被按两个完整单位收费。 影响:所有使用 5s 视频基础价的模型,quantity 与 durationUnitCount 允许小数。新增迁移同步现存规则的旧 ceil 公式元数据。 验证:go vet ./...;pnpm lint;pnpm test;pnpm build;./tests/ci/migrations-test.sh
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
-4
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user