From 69b0c107d3289b84ccc2c5f73f60d998e4088cb0 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Tue, 21 Jul 2026 13:12:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(web):=20=E4=BF=AE=E6=AD=A3=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E6=B5=8B=E8=AF=95=E6=A8=A1=E5=9E=8B=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除可能将 image_to_video 误判为图像模型的子串回退,在没有匹配模型时显示空状态。 新增图像生成、图像编辑和视频模式回归测试。验证通过:前端 94 项测试、类型检查、lint 和生产构建。 --- .../src/pages/PlaygroundPage.models.test.ts | 48 +++++++++++++++++++ apps/web/src/pages/PlaygroundPage.tsx | 16 +++---- 2 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/pages/PlaygroundPage.models.test.ts diff --git a/apps/web/src/pages/PlaygroundPage.models.test.ts b/apps/web/src/pages/PlaygroundPage.models.test.ts new file mode 100644 index 0000000..dd52e6b --- /dev/null +++ b/apps/web/src/pages/PlaygroundPage.models.test.ts @@ -0,0 +1,48 @@ +import type { PlatformModel } from '@easyai-ai-gateway/contracts'; +import { describe, expect, it } from 'vitest'; +import { filterModelsForMode } from './PlaygroundPage'; + +function model(id: string, modelType: string[]) { + return { id, modelType } as PlatformModel; +} + +function modelIds(models: PlatformModel[]) { + return models.map((item) => item.id); +} + +describe('playground model filtering', () => { + const models = [ + model('image-generation', ['image_generate']), + model('legacy-image', ['image']), + model('image-edit', ['image_edit']), + model('image-to-video', ['video_generate', 'image_to_video']), + model('text-to-video', ['text_to_video']), + ]; + + it('only shows image generation models when no reference image is present', () => { + expect(modelIds(filterModelsForMode(models, 'image', false, 'text_to_video'))).toEqual([ + 'image-generation', + 'legacy-image', + ]); + }); + + it('only shows image editing models when a reference image is present', () => { + expect(modelIds(filterModelsForMode(models, 'image', true, 'text_to_video'))).toEqual([ + 'legacy-image', + 'image-edit', + ]); + }); + + it('does not fall back to image-to-video models when no image model is available', () => { + const videoOnlyModels = [ + model('kling-3-turbo', ['video_generate', 'image_to_video']), + model('kling-1-5', ['image_to_video']), + ]; + + expect(filterModelsForMode(videoOnlyModels, 'image', false, 'text_to_video')).toEqual([]); + }); + + it('keeps image-to-video models available for the matching video mode', () => { + expect(modelIds(filterModelsForMode(models, 'video', false, 'first_last_frame'))).toContain('image-to-video'); + }); +}); diff --git a/apps/web/src/pages/PlaygroundPage.tsx b/apps/web/src/pages/PlaygroundPage.tsx index 38daa15..e671aaf 100644 --- a/apps/web/src/pages/PlaygroundPage.tsx +++ b/apps/web/src/pages/PlaygroundPage.tsx @@ -810,7 +810,7 @@ function Composer(props: { {props.mode !== 'chat' && props.mediaSettings && props.onMediaSettingsChange && ( = { first_last_frame: ['video_first_last_frame', 'image_to_video', 'video_generate'], omni_reference: ['omni_video', 'video_reference', 'video_generate'], text_to_video: ['text_to_video', 'video_generate'], }; - return filterWithFallback(models, [...videoTypesByMode[videoMode], 'video']); + return filterModelsByType(models, [...videoTypesByMode[videoMode], 'video']); } -function filterWithFallback(models: PlatformModel[], modelTypes: string[]) { - const exact = models.filter((model) => model.modelType.some((type) => modelTypes.includes(type))); - return exact.length ? exact : models.filter((model) => modelTypes.some((type) => model.modelType.some((modelType) => modelType.includes(type) || type.includes(modelType)))); +function filterModelsByType(models: PlatformModel[], modelTypes: string[]) { + const acceptedTypes = new Set(modelTypes); + return models.filter((model) => model.modelType.some((type) => acceptedTypes.has(type))); } function buildModelOptions(models: PlatformModel[]): ModelOption[] { -- 2.54.0 From e3dfe8162b35759b567ca1c86c2474e079498d9c Mon Sep 17 00:00:00 2001 From: chengcheng Date: Tue, 21 Jul 2026 13:32:23 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(billing):=20=E8=A7=86=E9=A2=91=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E6=8C=89=E5=AE=9E=E9=99=85=E7=A7=92=E6=95=B0=E7=BA=BF?= =?UTF-8?q?=E6=80=A7=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将五秒基础价按 duration / 5 比例结算,保留 provider 返回的小数时长,避免六秒视频被按两个完整单位收费。 影响:所有使用 5s 视频基础价的模型,quantity 与 durationUnitCount 允许小数。新增迁移同步现存规则的旧 ceil 公式元数据。 验证:go vet ./...;pnpm lint;pnpm test;pnpm build;./tests/ci/migrations-test.sh --- apps/api/internal/runner/pricing.go | 22 ++-- apps/api/internal/runner/pricing_test.go | 14 +-- apps/api/internal/runner/pricing_v2.go | 33 +++++- apps/api/internal/runner/pricing_v2_test.go | 107 ++++++++++++++++++ ...el-billing-configuration-and-estimation.md | 8 +- .../0070_linear_video_duration_billing.sql | 14 +++ .../pages/admin/PricingRuleVisualEditor.tsx | 2 +- docs/design.md | 2 +- 8 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 apps/api/migrations/0070_linear_video_duration_billing.sql diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index 6366df2..f149c21 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -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 } diff --git a/apps/api/internal/runner/pricing_test.go b/apps/api/internal/runner/pricing_test.go index bb1b528..8c36e79 100644 --- a/apps/api/internal/runner/pricing_test.go +++ b/apps/api/internal/runner/pricing_test.go @@ -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 { diff --git a/apps/api/internal/runner/pricing_v2.go b/apps/api/internal/runner/pricing_v2.go index 53c32db..62ab266 100644 --- a/apps/api/internal/runner/pricing_v2.go +++ b/apps/api/internal/runner/pricing_v2.go @@ -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, diff --git a/apps/api/internal/runner/pricing_v2_test.go b/apps/api/internal/runner/pricing_v2_test.go index dd2acad..2041f16 100644 --- a/apps/api/internal/runner/pricing_v2_test.go +++ b/apps/api/internal/runner/pricing_v2_test.go @@ -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) diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-billing-configuration-and-estimation.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-billing-configuration-and-estimation.md index 8a40fc6..757dba7 100644 --- a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-billing-configuration-and-estimation.md +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-billing-configuration-and-estimation.md @@ -166,16 +166,16 @@ The response has this shape: "platformModelId": "", "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 ``` diff --git a/apps/api/migrations/0070_linear_video_duration_billing.sql b/apps/api/migrations/0070_linear_video_duration_billing.sql new file mode 100644 index 0000000..4badace --- /dev/null +++ b/apps/api/migrations/0070_linear_video_duration_billing.sql @@ -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; diff --git a/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx b/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx index 2e45e1a..6ec58b8 100644 --- a/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx +++ b/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx @@ -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 }, diff --git a/docs/design.md b/docs/design.md index 40ccf90..6f543e3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 模型: -- 2.54.0