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: { selectedModel: {
provider: "popiserver", provider: "popiserver",
modelId: "52", modelId: "52",
displayName: "Token Billed Video", displayName: "Seedance Token Video",
capabilities: ["text-to-video", "image-to-video"], capabilities: ["text-to-video", "image-to-video"],
metadata: { metadata: {
aiModelId: 52, 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 fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (url.startsWith("/api/points/quote")) { if (url.startsWith("/api/points/quote")) {
@ -1751,12 +1751,15 @@ describe("GenerationComposer", () => {
}); });
expect(screen.getByRole("button", { name: /Generate/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Generate/ })).toBeInTheDocument();
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); 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")); const priceCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/quote"));
expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toMatchObject({ expect(JSON.parse(String((priceCall?.[1] as RequestInit).body))).toMatchObject({
source: "estimate", source: "task-price",
payload: { payload: {
model_name: "token-image-model", type: 1,
parameters: {}, 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({ config: draft({
selectedModel: model({ selectedModel: model({
modelId: "123", modelId: "123",
displayName: "Token billed video", displayName: "Seedance token video",
billingMethod: 2, billingMethod: 2,
aiModelCode: "video-code", aiModelCode: "video-code",
aiModelCodeAlias: "video-alias", 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({ expect(buildPopiserverPriceQuoteRequest(context({
capability: "llm", capability: "llm",
config: draft(), config: draft(),
@ -83,10 +83,23 @@ describe("composerPricing", () => {
}))).toBeNull(); }))).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({ expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({ billingMethod: 2 }), selectedModel: model({ displayName: "Seedance Pro", billingMethod: 2 }),
}) }))).toMatchObject({ source: "estimate" }); }) }))).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({ expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({ selectedModel: model({
billingMethod: 1, billingMethod: 1,
@ -96,17 +109,15 @@ describe("composerPricing", () => {
subType: 101, subType: 101,
}), }),
}) }))).toMatchObject({ source: "task-price" }); }) }))).toMatchObject({ source: "task-price" });
// Seedance name no longer forces estimate; billingMethod 1 -> task-price. expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
expect(buildPopiserverPriceQuoteRequest(context({
capability: "video",
config: draft({
selectedModel: model({ selectedModel: model({
displayName: "Seedance Pro", billingMethod: 2,
billingMethod: 1, aiModelId: 33,
aiModelId: 44, 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)", () => { it("does not forward batchSize in estimate payloads (backend ignores it)", () => {
@ -136,7 +147,7 @@ describe("composerPricing", () => {
it("builds unified PopiServer quote requests", () => { it("builds unified PopiServer quote requests", () => {
const estimateContext = context({ config: draft({ const estimateContext = context({ config: draft({
selectedModel: model({ billingMethod: 2 }), selectedModel: model({ displayName: "Seedance Pro", billingMethod: 2 }),
}) }); }) });
expect(buildPopiserverPriceQuoteRequest(estimateContext)).toMatchObject({ expect(buildPopiserverPriceQuoteRequest(estimateContext)).toMatchObject({

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

@ -452,4 +452,63 @@ describe("generationConfig", () => {
}); });
expect(payload?.sound).toBeUndefined(); 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 { import {
buildPopiserverTaskPricePayload, buildPopiserverPricingParameterPayload,
type PopiserverTaskPricePayload, type PopiserverTaskPricePayload,
} from "@/utils/generationConfig"; } from "@/utils/generationConfig";
import { buildPopiserverLlmParameterPayload } from "@/utils/popiserverLlmPayload"; import { buildPopiserverLlmParameterPayload } from "@/utils/popiserverLlmPayload";
@ -53,13 +53,33 @@ function getPopiserverBillingMethod(model: SelectedModel): number | null {
return billingMethod === null ? null : Math.trunc(billingMethod); 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 { function shouldUsePopiserverPointsEstimate(model: SelectedModel): boolean {
if (model.provider !== "popiserver") return false; 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; return getPopiserverBillingMethod(model) === 2;
} }
function shouldUsePopiserverTaskPrice(model: SelectedModel): boolean { function shouldUsePopiserverTaskPrice(model: SelectedModel): boolean {
if (model.provider !== "popiserver") return false; 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; return getPopiserverBillingMethod(model) === 1;
} }
@ -145,7 +165,9 @@ function buildComposerPopiserverTaskPricePayload(
const { config, capability, media } = context; const { config, capability, media } = context;
if (!shouldUsePopiserverTaskPrice(config.selectedModel)) return null; if (!shouldUsePopiserverTaskPrice(config.selectedModel)) return null;
if (shouldUsePopiserverPointsEstimate(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, config,
capability === "llm" ? "all" : capability, capability === "llm" ? "all" : capability,
media media

17
src/utils/generationConfig.ts

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

Loading…
Cancel
Save