Use server enterprise video pricing
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled

This commit is contained in:
2026-06-10 14:27:42 +08:00
parent d28889fd0c
commit 9e080bbb8f
5 changed files with 330 additions and 49 deletions
+70
View File
@@ -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
View File
@@ -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;
},
};
+16 -6
View File
@@ -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,
+37
View File
@@ -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);
});
});
+102 -33
View File
@@ -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));
}