Files
ttstd-infinite-canvas/web/test/model-pricing.test.ts

285 lines
17 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
import type { ModelRequirements } from "../src/lib/model-selection";
import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store";
import { buildNodeConfig } from "../src/components/canvas/canvas-node-prompt-panel";
import { buildGenerationConfig } from "../src/lib/canvas/canvas-project-generation";
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; outputTokenPriceMicrocredits?: number }> }) {
const capability = input.capability || "video";
const model = capability === "video" ? "agnes-video-2.5" : "image-model";
const channel = createModelChannel({
id: "system-channel",
name: "系统渠道",
scope: "system",
baseUrl: "/api/system-channel",
apiKey: "system",
apiFormat: "openai",
models: [model],
modelCosts: [
{
model,
capability,
pricePolicy: "channel",
billingMode: input.tiers[0]?.billingMode || "fixed_request",
unitPriceMicrocredits: input.tiers[0]?.unitPriceMicrocredits || 0,
logicalModelId: input.logicalModelId,
logicalPriceTiers: input.tiers.map((tier) => ({
...tier,
resolution: tier.selector.vquality || "*",
videoSeconds: Number(tier.selector.videoSeconds || 0),
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: tier.outputTokenPriceMicrocredits ?? 0,
cachedTokenPriceMicrocredits: 0,
})),
},
],
});
return normalizeConfigSnapshot({
config: {
...defaultConfig,
channels: [channel],
model: `${channel.id}::${model}`,
imageModel: capability === "image" ? `${channel.id}::${model}` : defaultConfig.imageModel,
videoModel: capability === "video" ? `${channel.id}::${model}` : defaultConfig.videoModel,
quality: "high",
size: "16:9",
vquality: "720P",
videoSeconds: "5",
count: "3",
},
}).config;
}
const textVideoRequirements: ModelRequirements = {
capability: "video",
input: { textCount: 1, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 },
videoSeconds: "5",
options: { size: "16:9", vquality: "720", videoSeconds: 5 },
};
describe("model request pricing", () => {
test("keeps the panel quote and generation options aligned without selecting a price tier as quality", () => {
const config = systemConfig({
capability: "image",
tiers: [
{ selector: { quality: "1k" }, billingMode: "fixed_request", unitPriceMicrocredits: 1_000_000 },
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
{ selector: { quality: "4k" }, billingMode: "fixed_request", unitPriceMicrocredits: 4_000_000 },
],
});
for (const size of ["auto", "1024x1024", "2048x2048"]) {
const node: CanvasNodeData = { id: "image-1", type: CanvasNodeType.Image, title: "Image", position: { x: 0, y: 0 }, width: 400, height: 400, metadata: { model: config.model, quality: "auto", size } };
const requirements: ModelRequirements = { capability: "image", options: { quality: "auto", size } };
const panel = buildNodeConfig(config, node, "image", requirements);
const generation = buildGenerationConfig(config, node, "image", requirements);
expect(modelRequestOptions(panel, "image")).toEqual(modelRequestOptions(generation, "image"));
expect(panel.quality).toBe("auto");
}
});
test("uses image dimensions to match resolution pricing when quality is automatic", () => {
const config = systemConfig({
capability: "image",
tiers: [
{ selector: { quality: "1k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 3_000_000 },
{ selector: { quality: "4k" }, billingMode: "fixed_request", unitPriceMicrocredits: 5_000_000 },
],
});
for (const [size, price] of [["1024x1024", 2], ["2048x2048", 3], ["2880x2880", 5]] as const) {
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "image-model", capability: "image", config: { ...config, quality: "auto", size }, requirements: { capability: "image", options: { quality: "auto", size } }, count: 1 })).toBe(price);
}
});
test("matches each image resolution and generation operation for quotes and displayed costs", () => {
const qualities = ["1k", "2k", "4k"];
const config = systemConfig({
capability: "image",
logicalModelId: "logical-image",
tiers: ["text_to_image", "image_to_image"].flatMap((operation, operationIndex) =>
qualities.map((quality, index) => ({
selector: { operation, quality },
billingMode: "fixed_request" as const,
unitPriceMicrocredits: (index + 1 + operationIndex * 3) * 1_000_000,
})),
),
});
const channel = resolveModelChannel(config, config.model);
for (const imageCount of [0, 1]) {
for (const [index, quality] of qualities.entries()) {
const requirements: ModelRequirements = {
capability: "image",
input: { textCount: 1, imageCount, characterCount: 0, videoCount: 0, audioCount: 0 },
options: { quality: quality.toUpperCase(), size: "16:9" },
};
expect(requestCreditCost({ channelMode: "remote", modelCosts: channel.modelCosts, model: "image-model", capability: "image", config, requirements, count: 1 })).toBe(index + 1 + imageCount * 3);
expect(modelQuoteRequest(config, config.model, "image", requirements)?.intent).toMatchObject({
operation: imageCount ? "image_to_image" : "text_to_image",
inputs: { image: imageCount },
options: { quality: quality.toUpperCase() },
});
}
}
const characterRequirements: ModelRequirements = { capability: "image", input: { textCount: 1, imageCount: 0, characterCount: 1, videoCount: 0, audioCount: 0 }, options: { quality: "2k" } };
expect(modelQuoteRequest(config, config.model, "image", characterRequirements)?.intent).toMatchObject({ operation: "image_to_image", inputs: { image: 1 } });
expect(requestCreditCost({ channelMode: "remote", modelCosts: channel.modelCosts, model: "image-model", capability: "image", config, requirements: characterRequirements, count: 1 })).toBe(5);
expect(priceTiersForCurrentSelection(channel.modelCosts![0]!.logicalPriceTiers!, "image", { ...config, quality: "8k" })).toHaveLength(0);
});
test("prefers a matching image specification over the uniform fallback", () => {
const config = systemConfig({ capability: "image", tiers: [
{ selector: {}, billingMode: "fixed_request", unitPriceMicrocredits: 1_000_000 },
{ selector: { operation: "text_to_image", quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
] });
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "2k" })[0]?.unitPriceMicrocredits).toBe(2_000_000);
expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "4k" })[0]?.unitPriceMicrocredits).toBe(1_000_000);
});
test("匹配独立图片画质与像素分辨率,并兼容存量 2K 价格档", () => {
const config = systemConfig({
capability: "image",
tiers: [
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
{ selector: { quality: "medium", resolution: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 3_000_000 },
],
});
const requirements: ModelRequirements = { capability: "image", options: { quality: "medium", size: "2048x2048" } };
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "image-model", capability: "image", config, requirements, count: 1 })).toBe(3);
expect(priceTiersForCurrentSelection(resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!, "image", config, requirements)[0]?.unitPriceMicrocredits).toBe(3_000_000);
});
test("preserves provider-specific resolution enums when matching price tiers", () => {
expect(normalizeTierResolution("768P竖")).toBe("768p竖");
expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");
});
test("matches the current Agnes resolution and duration tier and totals per-second credits", () => {
const config = systemConfig({
tiers: [
{ selector: { operation: "text_to_video", vquality: "720p", videoSeconds: "5" }, billingMode: "per_second", unitPriceMicrocredits: 25_000 },
{ selector: { operation: "text_to_video", vquality: "960p", videoSeconds: "5" }, billingMode: "per_second", unitPriceMicrocredits: 40_000 },
{ selector: { operation: "image_to_video", vquality: "720p", videoSeconds: "5", imageCount: "1" }, billingMode: "per_second", unitPriceMicrocredits: 30_000 },
],
});
const channel = resolveModelChannel(config, config.model);
expect(
requestCreditCost({
channelMode: "remote",
modelCosts: channel.modelCosts,
model: "agnes-video-2.5",
capability: "video",
config,
requirements: textVideoRequirements,
seconds: "5",
}),
).toBe(0.125);
});
test("displays zero-price system tiers as configured free pricing", () => {
const fixedConfig = systemConfig({
capability: "image",
tiers: [{ selector: {}, billingMode: "fixed_request", unitPriceMicrocredits: 0 }],
});
const perSecondConfig = systemConfig({
tiers: [{ selector: {}, billingMode: "per_second", unitPriceMicrocredits: 0 }],
});
expect(priceTierSummaryLabel(resolveModelChannel(fixedConfig, fixedConfig.model).modelCosts![0]!.logicalPriceTiers!)).toBe("0 积分");
expect(priceTierSummaryLabel(resolveModelChannel(perSecondConfig, perSecondConfig.model).modelCosts![0]!.logicalPriceTiers!)).toBe("0 积分/秒");
});
test("uses reference count and operation to avoid a text-video price tier", () => {
const config = systemConfig({
tiers: [
{ selector: { operation: "text_to_video", vquality: "720p", videoSeconds: "5" }, billingMode: "per_second", unitPriceMicrocredits: 25_000 },
{ selector: { operation: "image_to_video", vquality: "720p", videoSeconds: "5", imageCount: "1" }, billingMode: "per_second", unitPriceMicrocredits: 30_000 },
],
});
const requirements: ModelRequirements = {
...textVideoRequirements,
input: { ...textVideoRequirements.input!, imageCount: 1 },
};
const matched = priceTiersForCurrentSelection(resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!, "video", config, requirements);
expect(matched).toHaveLength(1);
expect(matched[0]?.unitPriceMicrocredits).toBe(30_000);
});
test("multiplies fixed image request pricing by output count", () => {
const config = systemConfig({
capability: "image",
tiers: [{ selector: { quality: "high", size: "16:9" }, billingMode: "fixed_request", unitPriceMicrocredits: 10_000 }],
});
const channel = resolveModelChannel(config, config.model);
expect(
requestCreditCost({
channelMode: "remote",
modelCosts: channel.modelCosts,
model: "image-model",
capability: "image",
config,
requirements: { capability: "image" },
count: 3,
}),
).toBe(0.03);
});
test("builds a logical-model quote using the normalized current request", () => {
const config = systemConfig({
logicalModelId: "logical-video-1",
tiers: [{ selector: {}, billingMode: "per_second", unitPriceMicrocredits: 25_000 }],
});
const quote = modelQuoteRequest(config, config.model, "video", textVideoRequirements);
expect(quote).toMatchObject({
logicalModelID: "logical-video-1",
intent: {
capability: "video",
operation: "text_to_video",
inputs: { image: 0, video: 0, audio: 0 },
options: { vquality: "720", videoSeconds: 5 },
},
});
});
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();
});
});