Browse Source

积分查询处理

feature/video-erase
Luckyu_js 1 week ago
parent
commit
f25f799f2e
  1. 17
      src/components/__tests__/GenerationComposer.test.tsx
  2. 39
      src/utils/__tests__/composerPricing.test.ts
  3. 59
      src/utils/__tests__/generationConfig.test.ts
  4. 26
      src/utils/composerPricing.ts
  5. 17
      src/utils/generationConfig.ts

17
src/components/__tests__/GenerationComposer.test.tsx

@ -1354,7 +1354,7 @@ describe("GenerationComposer", () => {
selectedModel: {
provider: "popiserver",
modelId: "52",
displayName: "Token Billed Video",
displayName: "Seedance Token Video",
capabilities: ["text-to-video", "image-to-video"],
metadata: {
aiModelId: 52,
@ -1708,7 +1708,7 @@ describe("GenerationComposer", () => {
});
});
it("uses the estimate endpoint for PopiServer billingMethod 2 models", async () => {
it("uses task-price for non-Seedance models even when billingMethod is 2", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/points/quote")) {
@ -1751,12 +1751,15 @@ describe("GenerationComposer", () => {
});
expect(screen.getByRole("button", { name: /Generate/ })).toBeInTheDocument();
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false);
const estimateCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/quote"));
expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toMatchObject({
source: "estimate",
const priceCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/quote"));
expect(JSON.parse(String((priceCall?.[1] as RequestInit).body))).toMatchObject({
source: "task-price",
payload: {
model_name: "token-image-model",
parameters: {},
type: 1,
subType: 102,
aiModelId: 32,
aiModelCode: "token-image-model",
aiModelCodeAlias: "token-image-model",
},
});
});

39
src/utils/__tests__/composerPricing.test.ts

@ -47,7 +47,7 @@ describe("composerPricing", () => {
config: draft({
selectedModel: model({
modelId: "123",
displayName: "Token billed video",
displayName: "Seedance token video",
billingMethod: 2,
aiModelCode: "video-code",
aiModelCodeAlias: "video-alias",
@ -70,7 +70,7 @@ describe("composerPricing", () => {
});
});
it("skips quote requests for llm and models without a billing method", () => {
it("skips quote requests for llm drafts and Seedance models without a billing method", () => {
expect(buildPopiserverPriceQuoteRequest(context({
capability: "llm",
config: draft(),
@ -83,10 +83,23 @@ describe("composerPricing", () => {
}))).toBeNull();
});
it("routes purely by billingMethod (2 -> estimate, 1 -> task-price)", () => {
it("routes Seedance models by billingMethod and everything else to task-price", () => {
// Seedance + billingMethod 2 -> estimate
expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({ billingMethod: 2 }),
selectedModel: model({ displayName: "Seedance Pro", billingMethod: 2 }),
}) }))).toMatchObject({ source: "estimate" });
// Seedance + billingMethod 1 -> task-price
expect(buildPopiserverPriceQuoteRequest(context({
capability: "video",
config: draft({
selectedModel: model({
displayName: "Seedance Pro",
billingMethod: 1,
aiModelId: 44,
}),
}),
}))).toMatchObject({ source: "task-price" });
// Non-Seedance models always use task-price, regardless of billingMethod.
expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({
billingMethod: 1,
@ -96,17 +109,15 @@ 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({
expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({
displayName: "Seedance Pro",
billingMethod: 1,
aiModelId: 44,
}),
billingMethod: 2,
aiModelId: 33,
aiModelCode: "image-code",
type: 1,
subType: 101,
}),
}))).toMatchObject({ source: "task-price" });
}) }))).toMatchObject({ source: "task-price" });
});
it("does not forward batchSize in estimate payloads (backend ignores it)", () => {
@ -136,7 +147,7 @@ describe("composerPricing", () => {
it("builds unified PopiServer quote requests", () => {
const estimateContext = context({ config: draft({
selectedModel: model({ billingMethod: 2 }),
selectedModel: model({ displayName: "Seedance Pro", billingMethod: 2 }),
}) });
expect(buildPopiserverPriceQuoteRequest(estimateContext)).toMatchObject({

59
src/utils/__tests__/generationConfig.test.ts

@ -452,4 +452,63 @@ describe("generationConfig", () => {
});
expect(payload?.sound).toBeUndefined();
});
it("includes reference images in the task price payload when the model supports them", () => {
const payload = buildPopiserverTaskPricePayload(
{
prompt: "prompt",
selectedModel: {
provider: "popiserver",
modelId: "40",
displayName: "Popi Image Edit",
capabilities: ["image-to-image"],
billingMethod: 1,
aiModelId: 40,
type: 1,
subType: 103,
aiModelCode: "img-edit",
isSupportImages: true,
},
inputImages: ["https://static.popi.art/in-1.png"],
batchSize: 1,
subType: 103,
referenceSubjectList: [],
parameters: {},
extraTaskParams: {},
},
"image"
);
// Pricing must carry the same reference media the created task would, so the
// quote matches the actual charge for image-editing / reference models.
expect(payload?.images).toEqual(["https://static.popi.art/in-1.png"]);
});
it("omits reference media from the task price payload when the model does not support it", () => {
const payload = buildPopiserverTaskPricePayload(
{
prompt: "prompt",
selectedModel: {
provider: "popiserver",
modelId: "41",
displayName: "Popi Text To Image",
capabilities: ["text-to-image"],
billingMethod: 1,
aiModelId: 41,
type: 1,
subType: 102,
aiModelCode: "t2i",
},
inputImages: ["https://static.popi.art/in-1.png"],
batchSize: 1,
subType: 102,
referenceSubjectList: [],
parameters: {},
extraTaskParams: {},
},
"image"
);
expect(payload?.images).toBeUndefined();
});
});

26
src/utils/composerPricing.ts

@ -1,5 +1,5 @@
import {
buildPopiserverTaskPricePayload,
buildPopiserverPricingParameterPayload,
type PopiserverTaskPricePayload,
} from "@/utils/generationConfig";
import { buildPopiserverLlmParameterPayload } from "@/utils/popiserverLlmPayload";
@ -53,13 +53,33 @@ function getPopiserverBillingMethod(model: SelectedModel): number | null {
return billingMethod === null ? null : Math.trunc(billingMethod);
}
/**
* Whether the model is a Seedance model, detected by any identifying field
* (modelId / displayName / aiModelCode / aiModelCodeAlias) starting with
* "seedance" (case-insensitive). Only Seedance models route by billingMethod;
* every other model is priced via task-price.
*/
function isSeedanceModel(model: SelectedModel): boolean {
return [
model.modelId,
model.displayName,
stringFromUnknown(selectedModelMetadataValue(model, "aiModelCode")),
stringFromUnknown(selectedModelMetadataValue(model, "aiModelCodeAlias")),
].some((value) => typeof value === "string" && value.trim().toLowerCase().startsWith("seedance"));
}
function shouldUsePopiserverPointsEstimate(model: SelectedModel): boolean {
if (model.provider !== "popiserver") return false;
// Only Seedance models route by billingMethod; estimate is used when billingMethod === 2.
if (!isSeedanceModel(model)) return false;
return getPopiserverBillingMethod(model) === 2;
}
function shouldUsePopiserverTaskPrice(model: SelectedModel): boolean {
if (model.provider !== "popiserver") return false;
// Non-Seedance models always use task-price (calculateTaskPrice).
// Seedance models use task-price only when billingMethod === 1.
if (!isSeedanceModel(model)) return true;
return getPopiserverBillingMethod(model) === 1;
}
@ -145,7 +165,9 @@ function buildComposerPopiserverTaskPricePayload(
const { config, capability, media } = context;
if (!shouldUsePopiserverTaskPrice(config.selectedModel)) return null;
if (shouldUsePopiserverPointsEstimate(config.selectedModel)) return null;
return buildPopiserverTaskPricePayload(
// Non-Seedance models take this path regardless of billingMethod, so price the
// task directly from parameters rather than gating on the per-run billing flag.
return buildPopiserverPricingParameterPayload(
config,
capability === "llm" ? "all" : capability,
media

17
src/utils/generationConfig.ts

@ -8,7 +8,7 @@ import type {
WorkflowNodeData,
} from "@/types";
import type { ComposerReferenceSubject } from "@/utils/composerReferenceSubjects";
import { selectedModelCapabilities, selectedModelMetadataValue } from "@/utils/selectedModel";
import { selectedModelCapabilities, selectedModelMetadata, selectedModelMetadataValue } from "@/utils/selectedModel";
import type { GenerationInput, ModelCapability, ModelParameter } from "@/lib/providers/types";
import { buildPopiTaskBody } from "@/utils/popiserverTaskPayload";
@ -468,15 +468,12 @@ export function buildPopiserverPricingParameterPayload(
provider: "popiserver",
capabilities: selectedModelCapabilities(model) as ModelCapability[],
description: null,
metadata: {
billingMethod: selectedModelMetadataValue(model, "billingMethod"),
aiModelId: selectedModelMetadataValue(model, "aiModelId"),
aiModelCode: selectedModelMetadataValue(model, "aiModelCode"),
aiModelCodeAlias: selectedModelMetadataValue(model, "aiModelCodeAlias"),
type: selectedModelMetadataValue(model, "type"),
subType: selectedModelMetadataValue(model, "subType"),
subTypes: selectedModelMetadataValue(model, "subTypes"),
},
// Use the same full metadata as the task-create path (create/route.ts).
// buildPopiTaskBody gates images/videos/voices on the isSupportImages /
// isSupportVideos / isSupportAudios flags via modelSupportsReferenceMedia,
// so the pricing payload must carry those flags to include reference media
// and stay consistent with the created task.
metadata: selectedModelMetadata(model),
},
prompt: config.prompt,
images: media.images ?? config.inputImages,

Loading…
Cancel
Save