294 lines
13 KiB
Go
294 lines
13 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestSummarizeUsesFenAndDoesNotProduceNegativeConsumption(t *testing.T) {
|
|
got := Summarize([]LedgerEntry{{Kind: "recharge", DeltaFen: 1000}, {Kind: "adjustment", DeltaFen: 100}, {Kind: "charge", DeltaFen: -400}, {Kind: "refund", DeltaFen: 500}})
|
|
if got.RechargeFen != 1100 || got.ChargedFen != 400 || got.RefundedFen != 500 || got.NetConsumedFen != 0 {
|
|
t.Fatalf("summary=%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeBillingParametersUsesGenerationPayloadShapes(t *testing.T) {
|
|
payload := map[string]any{
|
|
"settings": map[string]any{"resolution": " 1080P ", "ratio": "16:9", "duration": 5.2},
|
|
"providerPayload": map[string]any{"parameters": map[string]any{"n": 1.2}, "width": 1024.0, "height": 768.0},
|
|
"imageUrls": []any{"a", "b"},
|
|
}
|
|
got := NormalizeBillingParameters(payload, Parameters{"resolution": "480p", "callerOnly": true})
|
|
want := Parameters{"resolution": "1080p", "size": "1024*768", "aspectRatio": "16:9", "duration": 5.2, "imageCount": 2.0, "referenceImageCount": 2.0, "callerOnly": true}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("parameters = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestServiceQuoteNormalizesPayloadAndSeedsDefaultsThroughOptionalCapability(t *testing.T) {
|
|
store := "eStoreStub{rules: []PriceRule{{ID: "720", Provider: "seedance", Capability: "video.generate", ReqKey: "seedance-2", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 99, MarkupMultiplier: 1.2, Enabled: true}}}
|
|
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
|
Provider: "seedance", Capability: "video.generate", ReqKey: "seedance-2",
|
|
Parameters: Parameters{"resolution": "1080p"},
|
|
Payload: map[string]any{"settings": map[string]any{"resolution": "720P", "duration": 5.0}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.seedCalls != 1 || len(store.seeded) == 0 {
|
|
t.Fatalf("seed calls = %d, rules = %d", store.seedCalls, len(store.seeded))
|
|
}
|
|
if quote.PriceRuleID != "720" || quote.Quantity != 5 || quote.Parameters["resolution"] != "720p" {
|
|
t.Fatalf("quote = %#v", quote)
|
|
}
|
|
}
|
|
|
|
func TestServiceQuoteSupportsEvoLinkThreeByFourPreset(t *testing.T) {
|
|
store := "eStoreStub{rules: []PriceRule{{
|
|
ID: "evolink-3-4", Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2",
|
|
Unit: UnitImage, StandardUnitPriceFen: 34, MarkupMultiplier: 1.2, Enabled: true, Dimensions: evolinkDimensions(),
|
|
}}}
|
|
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
|
Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2",
|
|
Payload: map[string]any{"input": map[string]any{
|
|
"width": 1728.0, "height": 2304.0, "quality": "medium",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if quote.AmountFen != 41 || quote.Parameters["aspectRatio"] != "3:4" {
|
|
t.Fatalf("quote = %#v", quote)
|
|
}
|
|
}
|
|
|
|
func TestServiceQuoteDisabledReturnsNilWithoutAccessingRules(t *testing.T) {
|
|
store := "eStoreStub{failOnAccess: true}
|
|
quote, err := NewService(store, nil).SetEnabled(false).Quote(context.Background(), QuoteCommand{Provider: "seedance", Capability: "video.generate"})
|
|
if err != nil || quote != nil {
|
|
t.Fatalf("quote = %#v, err = %v", quote, err)
|
|
}
|
|
if store.seedCalls != 0 || store.listCalls != 0 {
|
|
t.Fatalf("seed calls = %d, list calls = %d", store.seedCalls, store.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestServiceCatalogReadsReturnSeedErrors(t *testing.T) {
|
|
seedErr := errors.New("catalog refresh failed")
|
|
for name, read := range map[string]func(*Service) error{
|
|
"admin overview": func(service *Service) error {
|
|
_, err := service.AdminOverview(context.Background())
|
|
return err
|
|
},
|
|
"price list": func(service *Service) error {
|
|
_, err := service.ListPrices(context.Background())
|
|
return err
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
store := "eStoreStub{seedErr: seedErr}
|
|
if err := read(NewService(store, nil)); !errors.Is(err, seedErr) {
|
|
t.Fatalf("error = %v, want %v", err, seedErr)
|
|
}
|
|
if store.listCalls != 0 {
|
|
t.Fatal("returned a stale price list after catalog refresh failed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceQuoteFreezesConservativeSeedanceReserve(t *testing.T) {
|
|
store := "eStoreStub{rules: []PriceRule{{
|
|
ID: "seedance-720", Provider: "seedance", Capability: "video.generate",
|
|
ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=720p",
|
|
Unit: UnitVideoSecond, StandardUnitPriceFen: 99, MarkupMultiplier: 1.2, Enabled: true,
|
|
}}}
|
|
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
|
Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128",
|
|
Payload: map[string]any{
|
|
"settings": map[string]any{"duration": 5.0, "resolution": "720p", "ratio": "9:16"},
|
|
"promptAssembly": map[string]any{"materials": []any{map[string]any{
|
|
"type": "video", "url": "https://example.test/reference.mp4",
|
|
}}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if quote.AmountFen != 1452 || quote.ReservedAmountFen != 1452 || quote.SettlementStatus != "pending" || quote.Parameters["inputVideo"] != true {
|
|
t.Fatalf("quote = %#v", quote)
|
|
}
|
|
}
|
|
|
|
func TestServiceQuoteUsesSeedance25CatalogAndTokenEstimate(t *testing.T) {
|
|
store := "eStoreStub{rules: []PriceRule{{
|
|
ID: "seedance-25-720", Provider: "seedance", Capability: "video.generate",
|
|
ReqKey: Seedance25Model, VariantKey: "resolution=720p", Unit: UnitVideoSecond,
|
|
StandardUnitPriceFen: 151, MarkupMultiplier: 1.2, Enabled: true,
|
|
}}}
|
|
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
|
Provider: "seedance", Capability: "video.generate", ReqKey: Seedance25Model,
|
|
Payload: map[string]any{"settings": map[string]any{"duration": 5.0, "resolution": "720p", "ratio": "16:9"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if quote.AmountFen != 908 || quote.ReservedAmountFen != 908 || quote.MarkupMultiplier != 1.2 {
|
|
t.Fatalf("quote = %#v", quote)
|
|
}
|
|
}
|
|
|
|
func TestServiceQuoteReservesMinimaxH3OfficialRateWithMarkup(t *testing.T) {
|
|
store := "eStoreStub{rules: []PriceRule{{
|
|
ID: "minimax-h3-2k", Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model,
|
|
VariantKey: "resolution=2k", Unit: UnitVideoSecond, StandardUnitPriceFen: 80, MarkupMultiplier: 1.2, Enabled: true,
|
|
}}}
|
|
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
|
Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model,
|
|
Payload: map[string]any{"settings": map[string]any{"duration": 5.0, "resolution": "2K", "ratio": "16:9"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if quote.AmountFen != 480 || quote.ReservedAmountFen != 480 || quote.SettlementStatus != "pending" || quote.Parameters["resolution"] != "2k" {
|
|
t.Fatalf("quote=%#v", quote)
|
|
}
|
|
}
|
|
|
|
func TestServiceQuoteAppliesSeedreamSizeAndReferenceInputPricing(t *testing.T) {
|
|
store := "eStoreStub{rules: []PriceRule{{
|
|
ID: "seedream-5", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel,
|
|
Unit: UnitImage, StandardUnitPriceFen: 30, MarkupMultiplier: 1.2, Enabled: true, Dimensions: seedreamDimensions(),
|
|
}}}
|
|
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
|
Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel,
|
|
Payload: map[string]any{
|
|
"settings": map[string]any{"size": "2K", "imageCount": 1.0},
|
|
"materials": []any{
|
|
map[string]any{"type": "image", "url": "https://in.test/1.png"},
|
|
map[string]any{"type": "image", "url": "https://in.test/2.png"},
|
|
map[string]any{"type": "image", "url": "https://in.test/3.png"},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 2K output = 60 fen, two billable references = 4 fen, then 1.2x.
|
|
if quote.StandardUnitPriceFen != 60 || quote.AmountFen != 77 || quote.Parameters["referenceImageCount"] != float64(3) || quote.Parameters["inputReferencePriceFen"] != float64(4) {
|
|
t.Fatalf("quote=%#v", quote)
|
|
}
|
|
}
|
|
|
|
func TestCalculateSeedreamAmountFenOfficialVectors(t *testing.T) {
|
|
tests := []struct {
|
|
output, references int
|
|
want int64
|
|
}{{30, 0, 36}, {30, 1, 36}, {30, 3, 41}, {60, 3, 77}, {60, 10, 94}}
|
|
for _, test := range tests {
|
|
got, err := CalculateSeedreamAmountFen(int64(test.output), test.references, 1.2)
|
|
if err != nil || got != test.want {
|
|
t.Fatalf("output=%d refs=%d got=%d err=%v want=%d", test.output, test.references, got, err, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServiceQuoteReservesSeedreamLayerMaximumAndSettlesByPixelTier(t *testing.T) {
|
|
store := "eStoreStub{rules: []PriceRule{
|
|
{ID: "seedream-basic", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 30, MarkupMultiplier: 1.2, Enabled: true, Dimensions: seedreamDimensions()},
|
|
{ID: "seedream-layers", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 15, MarkupMultiplier: 1.2, Enabled: true, Conditions: Conditions{"layerDecomposition": true}, Priority: 10, Dimensions: seedreamDimensions()},
|
|
}}
|
|
quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{
|
|
Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel,
|
|
Payload: map[string]any{
|
|
"settings": map[string]any{"size": "auto", "imageCount": 1.0, "creationMode": "layers", "layerDecomposition": true},
|
|
"materials": []any{map[string]any{"type": "image", "url": "https://in.test/source.png"}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if quote.PriceRuleID != "seedream-layers" || quote.Quantity != 17 || quote.AmountFen != 612 || quote.ReservedAmountFen != 612 || quote.SettlementStatus != "pending" {
|
|
t.Fatalf("quote=%#v", quote)
|
|
}
|
|
actual, err := CalculateSeedreamLayerAmountFen([]SeedreamLayerImage{{Width: 2048, Height: 2048}, {Width: 1273, Height: 265}}, 1.2)
|
|
if err != nil || actual != 54 {
|
|
t.Fatalf("actual=%d err=%v", actual, err)
|
|
}
|
|
}
|
|
|
|
func TestSeedanceEstimateMatchesExecutableTypeScriptVectors(t *testing.T) {
|
|
withoutVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
|
|
Model: Seedance20Model,
|
|
Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5, MarkupMultiplier: 1.2,
|
|
})
|
|
if err != nil || withoutVideo != 597 {
|
|
t.Fatalf("without video = %d, %v", withoutVideo, err)
|
|
}
|
|
withUnknownVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
|
|
Model: Seedance20Model,
|
|
Resolution: "720p", AspectRatio: "9:16", OutputDurationSeconds: 5,
|
|
InputVideo: true, MarkupMultiplier: 1.2,
|
|
})
|
|
if err != nil || withUnknownVideo != 1452 {
|
|
t.Fatalf("with video = %d, %v", withUnknownVideo, err)
|
|
}
|
|
seedance25, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
|
|
Model: Seedance25Model, Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5,
|
|
MarkupMultiplier: 1.2,
|
|
})
|
|
if err != nil || seedance25 != 908 {
|
|
t.Fatalf("seedance 2.5 without video = %d, %v", seedance25, err)
|
|
}
|
|
seedance25WithShortVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{
|
|
Model: Seedance25Model, Resolution: "720p", AspectRatio: "16:9", OutputDurationSeconds: 5,
|
|
InputVideo: true, InputVideoDurationSeconds: 2, MarkupMultiplier: 1.2,
|
|
})
|
|
if err != nil || seedance25WithShortVideo != 980 {
|
|
t.Fatalf("seedance 2.5 with short video = %d, %v", seedance25WithShortVideo, err)
|
|
}
|
|
}
|
|
|
|
type quoteStoreStub struct {
|
|
rules []PriceRule
|
|
seeded []PriceRule
|
|
seedCalls int
|
|
seedErr error
|
|
listCalls int
|
|
failOnAccess bool
|
|
}
|
|
|
|
func (s *quoteStoreStub) SeedBillingPriceRules(_ context.Context, rules []PriceRule) error {
|
|
s.seedCalls++
|
|
s.seeded = rules
|
|
return s.seedErr
|
|
}
|
|
func (s *quoteStoreStub) ListBillingPriceRules(context.Context, bool) ([]PriceRule, error) {
|
|
s.listCalls++
|
|
if s.failOnAccess {
|
|
panic("disabled billing accessed price rules")
|
|
}
|
|
return s.rules, nil
|
|
}
|
|
func (*quoteStoreStub) BillingWallet(context.Context, string) (Wallet, error) { return Wallet{}, nil }
|
|
func (*quoteStoreStub) BillingWallets(context.Context) ([]Wallet, error) { return nil, nil }
|
|
func (*quoteStoreStub) BillingLedger(context.Context, string, string, int) ([]LedgerEntry, error) {
|
|
return nil, nil
|
|
}
|
|
func (*quoteStoreStub) BillingOrganizations(context.Context) ([]Organization, error) { return nil, nil }
|
|
func (*quoteStoreStub) BillingMembers(context.Context) ([]Member, error) { return nil, nil }
|
|
func (*quoteStoreStub) BillingOrganizationExists(context.Context, string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
func (*quoteStoreStub) GetBillingPriceRule(context.Context, string) (*PriceRule, error) {
|
|
return nil, nil
|
|
}
|
|
func (*quoteStoreStub) UpdateBillingPriceRule(context.Context, string, PricePatch) (*PriceRule, error) {
|
|
return nil, nil
|
|
}
|
|
func (*quoteStoreStub) PostBillingWalletEntry(context.Context, WalletPostParams) (WalletPosting, error) {
|
|
return WalletPosting{}, nil
|
|
}
|