feat(*): 模型计费与渠道目录 - 支持视频 Token 报价结算和稳定模型展示
This commit is contained in:
@@ -28,11 +28,11 @@ describe("channel model editor drafts", () => {
|
||||
});
|
||||
test("capability changes clear old selectors but preserve money and billing units", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, imageCount: 2 };
|
||||
const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, videoGenerateAudio: "false", imageCount: 2 };
|
||||
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "video", providerModelKey: "gpt-image-2", priceTiers: [tier] }, protocols);
|
||||
expect(next.protocol).toBe("image");
|
||||
expect(next.capabilityConfig).toEqual(defaultModelCapabilityConfig("image", "gpt-image-2"));
|
||||
expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, imageCount: 0 });
|
||||
expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, videoGenerateAudio: "*", imageCount: 0 });
|
||||
expect(tier.operation).toBe("text_to_video");
|
||||
expect(() => validateChannelModelPrices(next)).toThrow("按秒");
|
||||
});
|
||||
@@ -63,16 +63,32 @@ describe("pricing write validation", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), unitPrice: unitPrice as number }] })).toThrow("有效数值");
|
||||
}
|
||||
});
|
||||
test("a new protocol cannot silently convert Token pricing to per request", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token");
|
||||
test("saves video Token prices with newapi-channel-2 and other video protocols without changing billing units", () => {
|
||||
for (const protocol of ["newapi-channel-2", "volcengine-jimeng-video", "video"]) {
|
||||
const priceTiers = [{ ...defaultPriceTier(), billingMode: "token" as const, outputTokenPrice: 8 }];
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol, priceTiers })).not.toThrow();
|
||||
expect(priceTiers[0].billingMode).toBe("token");
|
||||
expect(priceTiers[0].outputTokenPrice).toBe(8);
|
||||
}
|
||||
});
|
||||
test("validates all text Token prices and the video Token minimum", () => {
|
||||
test("rejects Token pricing after switching to image or audio", () => {
|
||||
for (const capability of ["image", "audio"] as const) {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token");
|
||||
}
|
||||
});
|
||||
test("validates all text Token prices and accepts free video Token pricing", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", inputTokenPrice: NaN }] })).toThrow();
|
||||
const video = { ...draft, capability: "video" as const, protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token" as const }] };
|
||||
expect(() => validateChannelModelPrices(video)).toThrow("至少为");
|
||||
expect(() => validateChannelModelPrices(video)).not.toThrow();
|
||||
video.priceTiers[0].outputTokenPrice = 0.000001;
|
||||
expect(() => validateChannelModelPrices(video)).not.toThrow();
|
||||
});
|
||||
test("accepts audio-only video price conditions and rejects invalid audio selectors", () => {
|
||||
for (const videoGenerateAudio of ["true", "false"]) {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", videoGenerateAudio }] })).not.toThrow();
|
||||
}
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), videoGenerateAudio: "yes" }] })).toThrow("有声或无声");
|
||||
});
|
||||
});
|
||||
|
||||
test("admin lazy routes share a persistent boundary instead of replaying page loaders", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { defaultPriceTier, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form";
|
||||
import { defaultPriceTier, priceTierPayloadFromForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form";
|
||||
import type { ChannelModelPriceTier } from "../src/services/api/wallet";
|
||||
|
||||
describe("channel model price tier defaults", () => {
|
||||
@@ -20,7 +20,7 @@ describe("channel model price tier defaults", () => {
|
||||
});
|
||||
|
||||
test("drops stale video selectors after switching back to the default price", () => {
|
||||
const tier = { ...defaultPriceTier("advanced"), resolution: "1080p", videoSeconds: 10, imageCount: 2 };
|
||||
const tier = { ...defaultPriceTier("advanced"), resolution: "1080p", videoSeconds: 10, videoGenerateAudio: "false", imageCount: 2 };
|
||||
const defaultTier = { ...tier, matchMode: "default" as const };
|
||||
|
||||
expect(skuSelectorFromForm("video", defaultTier)).toEqual({});
|
||||
@@ -51,5 +51,22 @@ describe("channel model price tier defaults", () => {
|
||||
|
||||
expect(priceTierToForm(base).matchMode).toBe("default");
|
||||
expect(priceTierToForm({ ...base, selector: { quality: "2k" } }).matchMode).toBe("advanced");
|
||||
expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "advanced", videoGenerateAudio: "false" });
|
||||
});
|
||||
|
||||
test("preserves explicit silent and audible selectors but omits the wildcard", () => {
|
||||
for (const videoGenerateAudio of ["true", "false"]) {
|
||||
const tier = { ...defaultPriceTier("advanced"), videoGenerateAudio };
|
||||
expect(skuSelectorFromForm("video", tier)).toEqual({ videoGenerateAudio });
|
||||
expect(skuSelectorFromForm("image", tier)).toEqual({});
|
||||
}
|
||||
expect(skuSelectorFromForm("video", defaultPriceTier("advanced"))).toEqual({});
|
||||
});
|
||||
|
||||
test("video Token writes discard hidden text prices without changing the configured video rate", () => {
|
||||
const tier = { ...defaultPriceTier("advanced"), billingMode: "token" as const, videoGenerateAudio: "false", inputTokenPrice: 10, outputTokenPrice: 0.25, cachedTokenPrice: 5 };
|
||||
expect(priceTierPayloadFromForm("video", tier, "seedance")).toMatchObject({ selector: { videoGenerateAudio: "false" }, providerModelKey: "seedance", inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 0 });
|
||||
expect(priceTierPayloadFromForm("text", tier, "text-model")).toMatchObject({ inputTokenPriceMicrocredits: 10_000_000, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 5_000_000 });
|
||||
expect(priceTierPayloadFromForm("video", { ...tier, outputTokenPrice: 0 }, "seedance").outputTokenPriceMicrocredits).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
93
web/test/model-picker-groups.test.ts
Normal file
93
web/test/model-picker-groups.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { groupModelsForPicker, modelChannelLabel } from "../src/lib/model-picker-groups";
|
||||
import { modelCompatibilityError, resolveCompatibleModel } from "../src/lib/model-selection";
|
||||
import { modelQuoteRequest, priceTiersForCurrentSelection, priceTierSummaryLabel } from "../src/lib/model-pricing";
|
||||
import { systemChannelModelChannels } from "../src/lib/user-session";
|
||||
import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/api/logical-models";
|
||||
import { defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability } from "../src/stores/use-config-store";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
|
||||
function model(label = "", price = 300000): PublicChannelModel {
|
||||
return {
|
||||
id: label || "test-model", modelKey: "seedance-2.0", displayName: "Seedance 2.0", channelLabel: label,
|
||||
description: label ? `${label}的使用说明` : "",
|
||||
icon: "ByteDance", capability: "video", protocol: "seedance", available: true,
|
||||
pricingMode: "provider", priceLabel: "",
|
||||
capabilityConfig: defaultModelCapabilityConfig("seedance", "seedance-2.0"),
|
||||
priceTiers: [{ id: "tier", selector: {}, resolution: "*", videoSeconds: 0, billingMode: "per_second", unitPriceMicrocredits: price, inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0, cachedTokenPriceMicrocredits: 0 }],
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const channels: PublicChannelCatalog[] = [
|
||||
{ id: "a", name: "正常渠道", displayName: "正常渠道", models: [model()] },
|
||||
{ id: "b", name: "渠道 B", displayName: "渠道 B", models: [model("优惠渠道-993")] },
|
||||
{ id: "c", name: "渠道 C", displayName: "渠道 C", models: [model("特惠渠道-730", 200000)] },
|
||||
];
|
||||
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(channels), model: "b::seedance-2.0", videoModel: "b::seedance-2.0" } }).config;
|
||||
}
|
||||
|
||||
test("one product exposes every channel and preserves prices through catalog normalization", () => {
|
||||
const config = fixture();
|
||||
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]);
|
||||
expect(groups[0]).toMatchObject({ label: "Seedance 2.0", icon: "ByteDance", kind: "product" });
|
||||
expect(groups[0].models.map((item) => [item.label, item.models])).toEqual([
|
||||
["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]],
|
||||
]);
|
||||
expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]);
|
||||
});
|
||||
|
||||
test("selection and quote keep the chosen channel even when another channel is cheaper", () => {
|
||||
const config = fixture();
|
||||
const value = "b::seedance-2.0";
|
||||
expect(resolveCompatibleModel(config, value, { capability: "video" })).toBe(value);
|
||||
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
|
||||
expect(modelQuoteRequest(config, value, "video")).toMatchObject({ channelId: "b", modelKey: "seedance-2.0" });
|
||||
config.channels[1].modelCosts![0].channelLabel = "新展示名";
|
||||
expect(modelChannelLabel(config, value)).toBe("新展示名");
|
||||
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
|
||||
});
|
||||
|
||||
test("product keys and capabilities stay distinct despite equal display names", () => {
|
||||
const config = fixture();
|
||||
config.channels[1].models = ["seedance-2.0-fast"];
|
||||
config.channels[1].modelCosts![0].model = "seedance-2.0-fast";
|
||||
config.channels[2].modelCosts![0].capability = "image";
|
||||
const groups = groupModelsForPicker(config, ["a::seedance-2.0", "b::seedance-2.0-fast", "c::seedance-2.0"]);
|
||||
expect(groups).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("group metadata is stable across channel reorder and current selection", () => {
|
||||
const config = fixture();
|
||||
config.channels[1].modelCosts![0].displayName = "不同展示名";
|
||||
config.channels[1].modelCosts![0].icon = "Jimeng";
|
||||
const options = selectableModelsByCapability(config, "video");
|
||||
const first = groupModelsForPicker(config, options)[0];
|
||||
const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options)[0];
|
||||
expect([reordered.label, reordered.icon]).toEqual([first.label, first.icon]);
|
||||
expect(reordered.models[0].models).toEqual(["c::seedance-2.0"]);
|
||||
});
|
||||
|
||||
test("each channel keeps its own capability restrictions", () => {
|
||||
const config = fixture();
|
||||
config.channels[0].modelCosts![0].capabilityConfig!.video!.references.maxImages = 0;
|
||||
config.channels[1].modelCosts![0].capabilityConfig!.video!.references.maxImages = 2;
|
||||
config.channels[1].modelCosts![0].capabilityConfig!.video!.operations = ["image_to_video"];
|
||||
const requirements = { capability: "video" as const, input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 } };
|
||||
expect(modelCompatibilityError(config, "a::seedance-2.0", requirements)).not.toBe("");
|
||||
expect(modelCompatibilityError(config, "b::seedance-2.0", requirements)).toBe("");
|
||||
expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video"))[0].models).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("managed logical models and personal channels never become system product routes", () => {
|
||||
const config = fixture();
|
||||
config.channels[1].id = "managed";
|
||||
config.channels[1].modelCosts![0].logicalModelId = "logical-test";
|
||||
config.channels[2].scope = "user";
|
||||
const groups = groupModelsForPicker(config, ["a::seedance-2.0", "managed::seedance-2.0", "c::seedance-2.0"]);
|
||||
expect(groups.map((item) => item.kind)).toEqual(["product", "channel", "channel"]);
|
||||
expect(groups[0].models).toHaveLength(1);
|
||||
expect(groupModelsForPicker(config, [])).toEqual([]);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { buildGenerationConfig } from "../src/lib/canvas/canvas-project-generati
|
||||
import { modelRequestOptions } from "../src/lib/model-selection";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record<string, string>; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number }> }) {
|
||||
function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record<string, string>; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number; outputTokenPriceMicrocredits?: number }> }) {
|
||||
const capability = input.capability || "video";
|
||||
const model = capability === "video" ? "agnes-video-2.5" : "image-model";
|
||||
const channel = createModelChannel({
|
||||
@@ -32,7 +32,7 @@ function systemConfig(input: { capability?: "image" | "video"; logicalModelId?:
|
||||
resolution: tier.selector.vquality || "*",
|
||||
videoSeconds: Number(tier.selector.videoSeconds || 0),
|
||||
inputTokenPriceMicrocredits: 0,
|
||||
outputTokenPriceMicrocredits: 0,
|
||||
outputTokenPriceMicrocredits: tier.outputTokenPriceMicrocredits ?? 0,
|
||||
cachedTokenPriceMicrocredits: 0,
|
||||
})),
|
||||
},
|
||||
@@ -235,4 +235,38 @@ describe("model request pricing", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("matches video audio, resolution and duration from actual request overrides", () => {
|
||||
const config = systemConfig({ tiers: [
|
||||
{ selector: {}, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 1_000_000 },
|
||||
{ selector: { vquality: "1080p", videoSeconds: "10", videoGenerateAudio: "false" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 2_000_000 },
|
||||
{ selector: { vquality: "1080p", videoSeconds: "10", videoGenerateAudio: "true" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 3_000_000 },
|
||||
] });
|
||||
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
|
||||
for (const videoGenerateAudio of [false, "false", true, "true"]) {
|
||||
const requirements = { ...textVideoRequirements, videoSeconds: "10", options: { vquality: "1080P", videoSeconds: 5, videoGenerateAudio } };
|
||||
const matched = priceTiersForCurrentSelection(tiers, "video", config, requirements);
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0]?.outputTokenPriceMicrocredits).toBe(videoGenerateAudio === true || videoGenerateAudio === "true" ? 3_000_000 : 2_000_000);
|
||||
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "agnes-video-2.5", capability: "video", config, requirements })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the configured video Token rate including free and fractional rates", () => {
|
||||
const config = systemConfig({ tiers: [
|
||||
{ selector: { videoGenerateAudio: "false" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0 },
|
||||
{ selector: { videoGenerateAudio: "true" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 1 },
|
||||
] });
|
||||
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
|
||||
expect(priceTierSummaryLabel(tiers, "video")).toBe("0-0.000001 积分/百万视频 Token");
|
||||
expect(priceTierSummaryLabel(tiers.slice(0, 1), "video")).toBe("0 积分/百万视频 Token");
|
||||
});
|
||||
|
||||
test("builds a direct system-video quote without inventing a logical model ID", () => {
|
||||
const config = systemConfig({ tiers: [{ selector: {}, billingMode: "token", unitPriceMicrocredits: 0 }] });
|
||||
const requirements = { ...textVideoRequirements, options: { videoGenerateAudio: false, vquality: "1080p" } };
|
||||
expect(modelQuoteRequest(config, config.model, "video", requirements)).toMatchObject({ channelId: "system-channel", modelKey: "agnes-video-2.5", intent: { options: { videoGenerateAudio: false, vquality: "1080p" } } });
|
||||
expect(modelQuoteRequest(config, config.model, "video", requirements)?.logicalModelID).toBeUndefined();
|
||||
expect(modelQuoteRequest(config, config.model, "image", requirements)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,21 @@ import { describe, expect, test } from "bun:test";
|
||||
import { modelProtocolSupportsTokenBilling } from "../src/lib/model-protocols";
|
||||
|
||||
describe("model protocol Token billing", () => {
|
||||
test("supports text models and Volcengine Ark video only", () => {
|
||||
test("supports text and every video protocol", () => {
|
||||
expect(modelProtocolSupportsTokenBilling("text", "chat-completion")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-agent-plan-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi-channel-2")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "custom-video-protocol")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not enable Token billing for image, audio or unselected capabilities", () => {
|
||||
expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-image")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-agent-plan-image")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("audio", "speech")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling(undefined, "volcengine-ark-video")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
66
web/test/model-quote.test.ts
Normal file
66
web/test/model-quote.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { modelQuoteDescription } from "../src/lib/model-pricing";
|
||||
import { quoteModel, type LogicalModelQuote, type ModelRequestIntent } from "../src/services/api/logical-models";
|
||||
import { apiClient } from "../src/services/api/request";
|
||||
|
||||
const intent: ModelRequestIntent = { capability: "video", operation: "text_to_video", inputs: { image: 0, video: 0, audio: 0 }, options: { videoSeconds: 5, videoGenerateAudio: false } };
|
||||
const quote: LogicalModelQuote = { logicalModelId: "", billingMode: "token", quantity: 1, amountMicrocredits: 500_000, estimated: true };
|
||||
|
||||
describe("model quote API targets", () => {
|
||||
test("direct system videos send the actual options and cancellation signal to the catalog quote", async () => {
|
||||
const original = apiClient.request;
|
||||
const calls: unknown[] = [];
|
||||
apiClient.request = (async (config: unknown) => {
|
||||
calls.push(config);
|
||||
return { data: { code: 0, data: { quote }, msg: "ok" }, status: 200, headers: {} };
|
||||
}) as typeof apiClient.request;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
expect(await quoteModel({ channelId: "channel-1", modelKey: "seedance", intent }, controller.signal)).toEqual({ quote });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({ method: "post", url: "/model-catalog/quote", data: { channelId: "channel-1", modelKey: "seedance", intent }, signal: controller.signal });
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("logical models retain their dedicated quote route", async () => {
|
||||
const original = apiClient.request;
|
||||
const calls: unknown[] = [];
|
||||
apiClient.request = (async (config: unknown) => {
|
||||
calls.push(config);
|
||||
return { data: { code: 0, data: { quote }, msg: "ok" }, status: 200, headers: {} };
|
||||
}) as typeof apiClient.request;
|
||||
try {
|
||||
await quoteModel({ logicalModelID: "logical/video", intent });
|
||||
expect(calls[0]).toMatchObject({ method: "post", url: "/models/logical%2Fvideo/quote", data: intent });
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("missing quote targets fail before issuing a request", async () => {
|
||||
await expect(quoteModel({ intent })).rejects.toThrow("请选择");
|
||||
});
|
||||
});
|
||||
|
||||
test("video estimates disclose platform reserves, unknown references and final settlement", () => {
|
||||
const description = modelQuoteDescription({
|
||||
...quote,
|
||||
videoTokenEstimate: { formulaTokens: 100_000, reservedTokens: 110_000, outputWidth: 1280, outputHeight: 720, framesPerSecond: 24, outputSeconds: 5, referenceSeconds: 15, referenceDurationEstimated: true, dimensionsEstimated: true, reservationMarginPercent: 10 },
|
||||
});
|
||||
expect(description).toContain("预计消耗 0.5 积分");
|
||||
expect(description).toContain("公式预估 100,000 视频 Token");
|
||||
expect(description).toContain("平台额外预留 10%");
|
||||
expect(description).toContain("15 秒");
|
||||
expect(description).toContain("并非费用上限");
|
||||
expect(description).toContain("未返回用量时按火山引擎公式结算");
|
||||
expect(description).toContain("额外预留不计入公式结算");
|
||||
expect(description).toContain("补扣或退回差额");
|
||||
});
|
||||
|
||||
test("text Token quotes retain upstream usage settlement without a video formula", () => {
|
||||
const description = modelQuoteDescription(quote);
|
||||
expect(description).toContain("实际用量结算");
|
||||
expect(description).not.toContain("火山引擎公式");
|
||||
});
|
||||
Reference in New Issue
Block a user