Use server enterprise video pricing
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
normalizeEnterpriseVideoPricingConfig,
|
||||
normalizePublicModelPrice,
|
||||
normalizePublicModelPrices,
|
||||
normalizePublicPricingPayload,
|
||||
} from "./publicPricingClient";
|
||||
|
||||
describe("publicPricingClient", () => {
|
||||
@@ -69,4 +71,72 @@ describe("publicPricingClient", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes public pricing payloads with model prices and enterprise video pricing", () => {
|
||||
expect(
|
||||
normalizePublicPricingPayload({
|
||||
modelPrices: [
|
||||
{
|
||||
modelKey: "qwen-turbo",
|
||||
pricingType: "token",
|
||||
inputPriceMills: 2,
|
||||
outputPriceMills: 6,
|
||||
},
|
||||
],
|
||||
enterpriseVideoPricing: {
|
||||
currency: "CNY",
|
||||
creditsPerCny: 100,
|
||||
billingUnit: "per_second",
|
||||
defaultResolution: "1080P",
|
||||
resolutions: ["720P", "1080P"],
|
||||
rules: [
|
||||
{
|
||||
id: "happyhorse",
|
||||
modelIncludes: ["happyhorse"],
|
||||
rates: { "720P": 0.72, "1080P": 1.28 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
modelPrices: [
|
||||
{
|
||||
id: undefined,
|
||||
modelKey: "qwen-turbo",
|
||||
displayName: undefined,
|
||||
category: undefined,
|
||||
pricingType: "token",
|
||||
inputPriceMills: 2,
|
||||
outputPriceMills: 6,
|
||||
flatPriceMills: null,
|
||||
currency: "CNY",
|
||||
enabled: true,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
],
|
||||
enterpriseVideoPricing: {
|
||||
currency: "CNY",
|
||||
creditsPerCny: 100,
|
||||
billingUnit: "per_second",
|
||||
defaultResolution: "1080P",
|
||||
resolutions: ["720P", "1080P"],
|
||||
rules: [
|
||||
{
|
||||
id: "happyhorse",
|
||||
modelIncludes: ["happyhorse"],
|
||||
rates: { "720P": 0.72, "1080P": 1.28 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed enterprise video pricing configs", () => {
|
||||
expect(
|
||||
normalizeEnterpriseVideoPricingConfig({
|
||||
rules: [{ id: "broken", modelIncludes: [], rates: {} }],
|
||||
}),
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
+105
-10
@@ -1,5 +1,6 @@
|
||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||
import { isRecord, serverRequest } from "./serverConnection";
|
||||
import type { EnterpriseVideoPricingConfig, EnterpriseVideoPricingRule } from "../utils/enterpriseVideoPolicy";
|
||||
|
||||
export interface PublicModelPrice {
|
||||
id?: number | string;
|
||||
@@ -16,6 +17,11 @@ export interface PublicModelPrice {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PublicPricingPayload {
|
||||
modelPrices: PublicModelPrice[];
|
||||
enterpriseVideoPricing: EnterpriseVideoPricingConfig | null;
|
||||
}
|
||||
|
||||
function readString(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
@@ -62,6 +68,51 @@ function readBoolean(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown>, keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value)) continue;
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeRateMap(raw: unknown): Record<string, number> | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
||||
if (Number.isFinite(parsed) && parsed >= 0) result[key] = parsed;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function normalizeEnterpriseVideoPricingRule(raw: unknown): EnterpriseVideoPricingRule | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const id = readString(raw, ["id", "key", "name"]);
|
||||
const modelIncludes = readStringArray(raw, ["modelIncludes", "model_includes", "modelPatterns", "model_patterns"]);
|
||||
const rates = normalizeRateMap(raw.rates);
|
||||
if (!id || modelIncludes.length === 0 || !rates) return null;
|
||||
|
||||
const when = isRecord(raw.when)
|
||||
? {
|
||||
...(typeof raw.when.muted === "boolean" ? { muted: raw.when.muted } : {}),
|
||||
...(typeof raw.when.hasReferenceVideo === "boolean"
|
||||
? { hasReferenceVideo: raw.when.hasReferenceVideo }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
modelIncludes,
|
||||
...(when && Object.keys(when).length ? { when } : {}),
|
||||
rates,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePublicModelPrice(
|
||||
raw: unknown,
|
||||
): PublicModelPrice | null {
|
||||
@@ -107,35 +158,79 @@ export function normalizePublicModelPrices(
|
||||
? payload
|
||||
: isRecord(payload) && Array.isArray(payload.prices)
|
||||
? payload.prices
|
||||
: isRecord(payload) && Array.isArray(payload.models)
|
||||
? payload.models
|
||||
: [];
|
||||
: isRecord(payload) && Array.isArray(payload.modelPrices)
|
||||
? payload.modelPrices
|
||||
: isRecord(payload) && Array.isArray(payload.model_prices)
|
||||
? payload.model_prices
|
||||
: isRecord(payload) && Array.isArray(payload.models)
|
||||
? payload.models
|
||||
: [];
|
||||
|
||||
return rawPrices
|
||||
.map((item) => normalizePublicModelPrice(item))
|
||||
.filter((item): item is PublicModelPrice => Boolean(item));
|
||||
}
|
||||
|
||||
let cachedPrices: PublicModelPrice[] | null = null;
|
||||
export function normalizeEnterpriseVideoPricingConfig(raw: unknown): EnterpriseVideoPricingConfig | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const rules = Array.isArray(raw.rules)
|
||||
? raw.rules
|
||||
.map((item) => normalizeEnterpriseVideoPricingRule(item))
|
||||
.filter((item): item is EnterpriseVideoPricingRule => Boolean(item))
|
||||
: [];
|
||||
if (rules.length === 0) return null;
|
||||
|
||||
const creditsPerCny = readNumber(raw, ["creditsPerCny", "credits_per_cny"]);
|
||||
const defaultResolution = readString(raw, ["defaultResolution", "default_resolution"]);
|
||||
const billingUnit = readString(raw, ["billingUnit", "billing_unit"]);
|
||||
const currency = readString(raw, ["currency"]);
|
||||
const resolutions = readStringArray(raw, ["resolutions", "supportedResolutions", "supported_resolutions"]);
|
||||
|
||||
return {
|
||||
...(currency ? { currency } : {}),
|
||||
...(creditsPerCny !== null ? { creditsPerCny } : {}),
|
||||
...(billingUnit ? { billingUnit } : {}),
|
||||
...(defaultResolution ? { defaultResolution } : {}),
|
||||
...(resolutions.length ? { resolutions } : {}),
|
||||
rules,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePublicPricingPayload(payload: unknown): PublicPricingPayload {
|
||||
const enterpriseVideoPricingRaw =
|
||||
isRecord(payload) && (payload.enterpriseVideoPricing ?? payload.enterprise_video_pricing);
|
||||
|
||||
return {
|
||||
modelPrices: normalizePublicModelPrices(payload),
|
||||
enterpriseVideoPricing: normalizeEnterpriseVideoPricingConfig(enterpriseVideoPricingRaw),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedPricing: PublicPricingPayload | null = null;
|
||||
let pricesRouteMissing = false;
|
||||
|
||||
export const publicPricingClient = {
|
||||
async getPrices(): Promise<PublicModelPrice[]> {
|
||||
if (cachedPrices) return cachedPrices;
|
||||
if (pricesRouteMissing) return [];
|
||||
async getPricing(): Promise<PublicPricingPayload> {
|
||||
if (cachedPricing) return cachedPricing;
|
||||
if (pricesRouteMissing) return { modelPrices: [], enterpriseVideoPricing: null };
|
||||
|
||||
try {
|
||||
const payload = await serverRequest<unknown>("prices", {
|
||||
fallbackMessage: "Model prices request failed",
|
||||
});
|
||||
cachedPrices = normalizePublicModelPrices(payload);
|
||||
return cachedPrices;
|
||||
cachedPricing = normalizePublicPricingPayload(payload);
|
||||
return cachedPricing;
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
pricesRouteMissing = true;
|
||||
return [];
|
||||
return { modelPrices: [], enterpriseVideoPricing: null };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getPrices(): Promise<PublicModelPrice[]> {
|
||||
const pricing = await publicPricingClient.getPricing();
|
||||
return pricing.modelPrices;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,7 +72,11 @@ import {
|
||||
import { isViduModel } from "../../utils/viduRouting";
|
||||
import { isPixverseModel } from "../../utils/pixverseRouting";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||
import {
|
||||
calculateEnterpriseVideoCredits,
|
||||
ENTERPRISE_DEFAULT_VIDEO_MODEL,
|
||||
type EnterpriseVideoPricingConfig,
|
||||
} from "../../utils/enterpriseVideoPolicy";
|
||||
import { resolveTextTokenCreditRate } from "../../utils/modelPricing";
|
||||
import {
|
||||
getImageQualityOptionsForContext,
|
||||
@@ -408,6 +412,7 @@ function WorkbenchPage({
|
||||
|
||||
const [chatModel, setChatModel] = useState(CHAT_MODEL_OPTIONS[0].value);
|
||||
const [modelPrices, setModelPrices] = useState<PublicModelPrice[]>([]);
|
||||
const [enterpriseVideoPricing, setEnterpriseVideoPricing] = useState<EnterpriseVideoPricingConfig | null>(null);
|
||||
const [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
|
||||
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_OPTIONS[0].value);
|
||||
|
||||
@@ -415,12 +420,16 @@ function WorkbenchPage({
|
||||
let cancelled = false;
|
||||
|
||||
publicPricingClient
|
||||
.getPrices()
|
||||
.then((prices) => {
|
||||
if (!cancelled) setModelPrices(prices);
|
||||
.getPricing()
|
||||
.then((pricing) => {
|
||||
if (cancelled) return;
|
||||
setModelPrices(pricing.modelPrices);
|
||||
setEnterpriseVideoPricing(pricing.enterpriseVideoPricing);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setModelPrices([]);
|
||||
if (cancelled) return;
|
||||
setModelPrices([]);
|
||||
setEnterpriseVideoPricing(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -566,7 +575,7 @@ function WorkbenchPage({
|
||||
durationSeconds,
|
||||
muted: false,
|
||||
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
|
||||
});
|
||||
}, enterpriseVideoPricing || undefined);
|
||||
return {
|
||||
label: `预计 ${formatCreditValue(credits)} 积分`,
|
||||
title: `${activeModel},${videoQualityLabel},${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
|
||||
@@ -589,6 +598,7 @@ function WorkbenchPage({
|
||||
activeModel,
|
||||
activeModelValue,
|
||||
imageSettingsSummary,
|
||||
enterpriseVideoPricing,
|
||||
referenceItems,
|
||||
selectedChatTokenRate,
|
||||
videoDuration,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
calculateEnterpriseVideoCredits,
|
||||
type EnterpriseVideoPricingConfig,
|
||||
getEnterpriseVideoCreditRate,
|
||||
normalizeEnterpriseResolution,
|
||||
} from "./enterpriseVideoPolicy";
|
||||
@@ -45,4 +46,40 @@ describe("enterpriseVideoPolicy", () => {
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("uses server-provided pricing config before fallback pricing", () => {
|
||||
const serverPricing: EnterpriseVideoPricingConfig = {
|
||||
creditsPerCny: 100,
|
||||
defaultResolution: "1080P",
|
||||
rules: [
|
||||
{
|
||||
id: "happyhorse-server",
|
||||
modelIncludes: ["happyhorse"],
|
||||
rates: { "720P": 2, "1080P": 3 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
getEnterpriseVideoCreditRate(
|
||||
{
|
||||
model: "happyhorse-1.0",
|
||||
resolution: "1080P",
|
||||
durationSeconds: 5,
|
||||
},
|
||||
serverPricing,
|
||||
),
|
||||
).toBe(3);
|
||||
|
||||
expect(
|
||||
calculateEnterpriseVideoCredits(
|
||||
{
|
||||
model: "happyhorse-1.0",
|
||||
resolution: "1080P",
|
||||
durationSeconds: 5,
|
||||
},
|
||||
serverPricing,
|
||||
),
|
||||
).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,50 +50,119 @@ export interface EnterpriseVideoPricingInput {
|
||||
hasReferenceVideo?: boolean;
|
||||
}
|
||||
|
||||
export interface EnterpriseVideoPricingRule {
|
||||
id: string;
|
||||
modelIncludes: string[];
|
||||
when?: {
|
||||
muted?: boolean;
|
||||
hasReferenceVideo?: boolean;
|
||||
};
|
||||
rates: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface EnterpriseVideoPricingConfig {
|
||||
currency?: string;
|
||||
creditsPerCny?: number;
|
||||
billingUnit?: "per_second" | string;
|
||||
defaultResolution?: string;
|
||||
resolutions?: string[];
|
||||
rules: EnterpriseVideoPricingRule[];
|
||||
}
|
||||
|
||||
export const FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG: EnterpriseVideoPricingConfig = {
|
||||
currency: "CNY",
|
||||
creditsPerCny: CREDITS_PER_CNY,
|
||||
billingUnit: "per_second",
|
||||
defaultResolution: ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
|
||||
resolutions: ["720P", "1080P"],
|
||||
rules: [
|
||||
{
|
||||
id: "happyhorse",
|
||||
modelIncludes: ["happyhorse"],
|
||||
rates: { "720P": 0.72, "1080P": 1.28 },
|
||||
},
|
||||
{
|
||||
id: "wanxiang-i2v",
|
||||
modelIncludes: ["wan2.7-i2v", "wanxiang"],
|
||||
rates: { "720P": 0.6, "1080P": 1 },
|
||||
},
|
||||
{
|
||||
id: "wan-animate-s2v",
|
||||
modelIncludes: ["animate-mix", "s2v"],
|
||||
rates: { "720P": 0.6, "1080P": 1 },
|
||||
},
|
||||
{
|
||||
id: "kling-muted-reference",
|
||||
modelIncludes: ["kling"],
|
||||
when: { muted: true, hasReferenceVideo: true },
|
||||
rates: { "720P": 0.9, "1080P": 1.2 },
|
||||
},
|
||||
{
|
||||
id: "kling-muted",
|
||||
modelIncludes: ["kling"],
|
||||
when: { muted: true, hasReferenceVideo: false },
|
||||
rates: { "720P": 0.6, "1080P": 0.8 },
|
||||
},
|
||||
{
|
||||
id: "kling-default",
|
||||
modelIncludes: ["kling"],
|
||||
rates: { "720P": 0.9, "1080P": 1.2 },
|
||||
},
|
||||
{
|
||||
id: "vidu",
|
||||
modelIncludes: ["vidu"],
|
||||
rates: { "720P": 0.6, "1080P": 1 },
|
||||
},
|
||||
{
|
||||
id: "pixverse",
|
||||
modelIncludes: ["pixverse"],
|
||||
rates: { "720P": 0.6, "1080P": 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function normalizeEnterpriseResolution(value: string): "720P" | "1080P" {
|
||||
return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P";
|
||||
}
|
||||
|
||||
export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput): number {
|
||||
function enterpriseVideoPricingRuleMatches(
|
||||
rule: EnterpriseVideoPricingRule,
|
||||
input: EnterpriseVideoPricingInput,
|
||||
model: string,
|
||||
): boolean {
|
||||
if (!rule.modelIncludes.some((pattern) => model.includes(String(pattern || "").toLowerCase()))) return false;
|
||||
if (!rule.when) return true;
|
||||
if ("muted" in rule.when && Boolean(input.muted) !== rule.when.muted) return false;
|
||||
if ("hasReferenceVideo" in rule.when && Boolean(input.hasReferenceVideo) !== rule.when.hasReferenceVideo) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getEnterpriseVideoCreditRate(
|
||||
input: EnterpriseVideoPricingInput,
|
||||
config: EnterpriseVideoPricingConfig = FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG,
|
||||
): number {
|
||||
const resolution = normalizeEnterpriseResolution(input.resolution);
|
||||
const model = String(input.model || "").toLowerCase();
|
||||
const fallbackResolution = normalizeEnterpriseResolution(
|
||||
config.defaultResolution || ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
|
||||
);
|
||||
const rule = config.rules.find((candidate) => enterpriseVideoPricingRuleMatches(candidate, input, model));
|
||||
|
||||
if (model.includes("happyhorse")) {
|
||||
return resolution === "720P" ? 0.72 : 1.28;
|
||||
}
|
||||
|
||||
if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) {
|
||||
return resolution === "720P" ? 0.6 : 1;
|
||||
}
|
||||
|
||||
if (model.includes("animate-mix")) {
|
||||
return resolution === "720P" ? 0.6 : 1;
|
||||
}
|
||||
|
||||
if (model.includes("s2v")) {
|
||||
return resolution === "720P" ? 0.6 : 1;
|
||||
}
|
||||
|
||||
if (model.includes("vidu")) {
|
||||
return resolution === "720P" ? 0.6 : 1.0;
|
||||
}
|
||||
|
||||
if (model.includes("pixverse")) {
|
||||
return resolution === "720P" ? 0.6 : 1.0;
|
||||
}
|
||||
|
||||
if (model.includes("kling")) {
|
||||
if (input.muted) {
|
||||
if (input.hasReferenceVideo) return resolution === "720P" ? 0.9 : 1.2;
|
||||
return resolution === "720P" ? 0.6 : 0.8;
|
||||
}
|
||||
return resolution === "720P" ? 0.9 : 1.2;
|
||||
if (rule) {
|
||||
const rate = rule.rates[resolution] ?? rule.rates[fallbackResolution];
|
||||
if (Number.isFinite(rate) && rate >= 0) return rate;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported enterprise video model: ${input.model}`);
|
||||
}
|
||||
|
||||
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
|
||||
export function calculateEnterpriseVideoCredits(
|
||||
input: EnterpriseVideoPricingInput,
|
||||
config: EnterpriseVideoPricingConfig = FALLBACK_ENTERPRISE_VIDEO_PRICING_CONFIG,
|
||||
): number {
|
||||
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
|
||||
return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
|
||||
const creditsPerCny = Number(config.creditsPerCny || CREDITS_PER_CNY);
|
||||
return Number((getEnterpriseVideoCreditRate(input, config) * duration * creditsPerCny).toFixed(2));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user