diff --git a/src/api/publicPricingClient.test.ts b/src/api/publicPricingClient.test.ts index fb4c963..c65d825 100644 --- a/src/api/publicPricingClient.test.ts +++ b/src/api/publicPricingClient.test.ts @@ -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); + }); }); diff --git a/src/api/publicPricingClient.ts b/src/api/publicPricingClient.ts index 0934340..dc20e3d 100644 --- a/src/api/publicPricingClient.ts +++ b/src/api/publicPricingClient.ts @@ -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, keys: string[], @@ -62,6 +68,51 @@ function readBoolean( return fallback; } +function readStringArray(record: Record, 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 | null { + if (!isRecord(raw)) return null; + const result: Record = {}; + 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 { - if (cachedPrices) return cachedPrices; - if (pricesRouteMissing) return []; + async getPricing(): Promise { + if (cachedPricing) return cachedPricing; + if (pricesRouteMissing) return { modelPrices: [], enterpriseVideoPricing: null }; try { const payload = await serverRequest("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 { + const pricing = await publicPricingClient.getPricing(); + return pricing.modelPrices; + }, }; diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index 7e79e1e..84ba4bf 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -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([]); + const [enterpriseVideoPricing, setEnterpriseVideoPricing] = useState(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, diff --git a/src/utils/enterpriseVideoPolicy.test.ts b/src/utils/enterpriseVideoPolicy.test.ts index 31e34e9..0123257 100644 --- a/src/utils/enterpriseVideoPolicy.test.ts +++ b/src/utils/enterpriseVideoPolicy.test.ts @@ -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); + }); }); diff --git a/src/utils/enterpriseVideoPolicy.ts b/src/utils/enterpriseVideoPolicy.ts index 359aa31..074a188 100644 --- a/src/utils/enterpriseVideoPolicy.ts +++ b/src/utils/enterpriseVideoPolicy.ts @@ -50,50 +50,119 @@ export interface EnterpriseVideoPricingInput { hasReferenceVideo?: boolean; } +export interface EnterpriseVideoPricingRule { + id: string; + modelIncludes: string[]; + when?: { + muted?: boolean; + hasReferenceVideo?: boolean; + }; + rates: Record; +} + +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)); }