diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index 5d21012d..5c0f94af 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -1761,7 +1761,7 @@ describe("GenerationComposer", () => { }); }); - it("uses the Seedance estimate endpoint for PopiServer Seedance models", async () => { + it("uses the estimate endpoint for PopiServer billingMethod 2 video models", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/points/quote")) { @@ -1791,7 +1791,7 @@ describe("GenerationComposer", () => { aiModelCodeAlias: "seedance-video-pro", type: 2, subType: POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, - billingMethod: 1, + billingMethod: 2, }, }, }), @@ -1864,7 +1864,7 @@ describe("GenerationComposer", () => { aiModelCodeAlias: "seedance-video-pro", type: 2, subType: POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, - billingMethod: 1, + billingMethod: 2, }, }, }), diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 245cf648..fa733973 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -39,7 +39,7 @@ import { } from "@/utils/composerReferenceSubjects"; import { buildPopiserverPriceQuoteRequest, - getQuotedPointAmount, + resolveQuotedPointAmount, type PopiserverPriceQuoteResponse, } from "@/utils/composerPricing"; import { @@ -700,7 +700,9 @@ function ComposerEditor({ signal: controller.signal, }); const data = response.ok ? await response.json() as PopiserverPriceQuoteResponse : null; - const amount = data ? getQuotedPointAmount(data) : null; + const amount = data + ? resolveQuotedPointAmount(data, quoteRequest.source, normalizedConfig.batchSize) + : null; if (priceRequestIdRef.current === requestId) { setPriceState({ amount, isLoading: false }); } diff --git a/src/utils/__tests__/composerPricing.test.ts b/src/utils/__tests__/composerPricing.test.ts index f4087660..7de2bf6d 100644 --- a/src/utils/__tests__/composerPricing.test.ts +++ b/src/utils/__tests__/composerPricing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildPopiserverPriceQuoteRequest, getQuotedPointAmount, + resolveQuotedPointAmount, } from "@/utils/composerPricing"; import type { SelectedModel } from "@/types"; import type { GenerationNodeConfig } from "@/utils/generationConfig"; @@ -64,13 +65,12 @@ describe("composerPricing", () => { duration: 8, input_video_duration_seconds: 12, }, - batchSize: 1, input_video_duration_seconds: 12, }, }); }); - it("skips task price quote requests for llm and Seedance estimates", () => { + it("skips quote requests for llm and models without a billing method", () => { expect(buildPopiserverPriceQuoteRequest(context({ capability: "llm", config: draft(), @@ -80,10 +80,10 @@ describe("composerPricing", () => { config: draft({ selectedModel: model({ displayName: "Seedance" }), }), - }))).toMatchObject({ source: "estimate" }); + }))).toBeNull(); }); - it("uses PopiServer estimate only for billingMethod 2 or Seedance models", () => { + it("routes purely by billingMethod (2 -> estimate, 1 -> task-price)", () => { expect(buildPopiserverPriceQuoteRequest(context({ config: draft({ selectedModel: model({ billingMethod: 2 }), }) }))).toMatchObject({ source: "estimate" }); @@ -96,34 +96,42 @@ describe("composerPricing", () => { subType: 101, }), }) }))).toMatchObject({ source: "task-price" }); + // Seedance name no longer forces estimate; billingMethod 1 -> task-price. expect(buildPopiserverPriceQuoteRequest(context({ capability: "video", config: draft({ selectedModel: model({ displayName: "Seedance Pro", billingMethod: 1, + aiModelId: 44, }), }), - }))).toMatchObject({ source: "estimate" }); + }))).toMatchObject({ source: "task-price" }); }); - it("forwards video count (batchSize) in Seedance estimate payloads", () => { + it("does not forward batchSize in estimate payloads (backend ignores it)", () => { const request = buildPopiserverPriceQuoteRequest(context({ capability: "video", config: draft({ - selectedModel: model({ displayName: "Seedance Pro", billingMethod: 1 }), + selectedModel: model({ displayName: "Seedance Pro", billingMethod: 2 }), parameters: { duration: 5 }, batchSize: 3, }), })); - expect(request).toMatchObject({ - source: "estimate", - payload: { - estimation_type: "auto", - batchSize: 3, - }, - }); + expect(request?.source).toBe("estimate"); + expect(request?.payload).not.toHaveProperty("batchSize"); + }); + + it("scales estimate points by batchSize but leaves task-price untouched", () => { + const response = { data: { points: 20 } }; + // estimate is a per-single quote -> multiply by video/image count + expect(resolveQuotedPointAmount(response, "estimate", 3)).toBe(60); + expect(resolveQuotedPointAmount(response, "estimate", undefined)).toBe(20); + expect(resolveQuotedPointAmount(response, "estimate", 0)).toBe(20); + // task-price already accounts for batchSize upstream + expect(resolveQuotedPointAmount(response, "task-price", 3)).toBe(20); + expect(resolveQuotedPointAmount({ data: { points: "bad" } }, "estimate", 3)).toBeNull(); }); it("builds unified PopiServer quote requests", () => { diff --git a/src/utils/composerPricing.ts b/src/utils/composerPricing.ts index f47aedcd..9c14501f 100644 --- a/src/utils/composerPricing.ts +++ b/src/utils/composerPricing.ts @@ -48,15 +48,6 @@ function stringFromUnknown(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function isSeedanceVideoEstimateModel(model: SelectedModel): boolean { - return /seedance/i.test([ - model.modelId, - model.displayName, - stringFromUnknown(selectedModelMetadataValue(model, "aiModelCode")), - stringFromUnknown(selectedModelMetadataValue(model, "aiModelCodeAlias")), - ].filter(Boolean).join(" ")); -} - function getPopiserverBillingMethod(model: SelectedModel): number | null { const billingMethod = finiteNumberFromUnknown(selectedModelMetadataValue(model, "billingMethod")); return billingMethod === null ? null : Math.trunc(billingMethod); @@ -64,7 +55,7 @@ function getPopiserverBillingMethod(model: SelectedModel): number | null { function shouldUsePopiserverPointsEstimate(model: SelectedModel): boolean { if (model.provider !== "popiserver") return false; - return getPopiserverBillingMethod(model) === 2 || isSeedanceVideoEstimateModel(model); + return getPopiserverBillingMethod(model) === 2; } function shouldUsePopiserverTaskPrice(model: SelectedModel): boolean { @@ -72,10 +63,6 @@ function shouldUsePopiserverTaskPrice(model: SelectedModel): boolean { return getPopiserverBillingMethod(model) === 1; } -function shouldUsePopiserverSeedanceEstimate(model: SelectedModel): boolean { - return model.provider === "popiserver" && isSeedanceVideoEstimateModel(model); -} - function getPopiserverModelName(model: SelectedModel): string { return ( stringFromUnknown(selectedModelMetadataValue(model, "aiModelCodeAlias")) || @@ -108,20 +95,6 @@ function buildPopiserverPointsEstimatePayload( }; } - if (shouldUsePopiserverSeedanceEstimate(selectedModel)) { - return { - model_name: getPopiserverModelName(selectedModel), - estimation_type: "auto", - parameters: { - ...(config.parameters ?? {}), - input_video_duration_seconds: inputVideoDurationSeconds, - }, - // Seedance pricing scales with the requested video count; forward batchSize - // top-level like the generic estimate branch so switching 视频数量 re-quotes. - ...(config.batchSize ? { batchSize: config.batchSize } : {}), - }; - } - return { model_name: getPopiserverModelName(selectedModel), estimation_type: "auto", @@ -134,7 +107,8 @@ function buildPopiserverPointsEstimatePayload( ...(inputVideoDurationSeconds > 0 ? { input_video_duration_seconds: inputVideoDurationSeconds } : {}), }, ...(Object.keys(config.extraTaskParams ?? {}).length > 0 ? { extraTaskParams: config.extraTaskParams } : {}), - ...(config.batchSize ? { batchSize: config.batchSize } : {}), + // The estimate endpoint does not support batchSize; it returns points for a + // single generation. batchSize is applied client-side in resolveQuotedPointAmount(). ...(config.audioVoiceId ? { audioVoiceId: config.audioVoiceId } : {}), ...(inputVideoDurationSeconds > 0 ? { input_video_duration_seconds: inputVideoDurationSeconds } : {}), }; @@ -144,6 +118,27 @@ export function getQuotedPointAmount(response: PopiserverPriceQuoteResponse): nu return finiteNumberFromUnknown(response.data?.points); } +/** + * Resolves the total point amount to display for a quote. + * + * The estimate endpoint quotes a single generation and ignores batchSize, so we + * multiply by the requested count on the client. task-price already accounts for + * batchSize upstream, so it is returned as-is. + */ +export function resolveQuotedPointAmount( + response: PopiserverPriceQuoteResponse, + source: PopiserverPriceQuoteSource, + batchSize: number | undefined +): number | null { + const points = getQuotedPointAmount(response); + if (points === null) return null; + if (source !== "estimate") return points; + const count = typeof batchSize === "number" && Number.isFinite(batchSize) && batchSize > 0 + ? Math.trunc(batchSize) + : 1; + return points * count; +} + function buildComposerPopiserverTaskPricePayload( context: ComposerPricingContext ) {