diff --git a/src/api/publicPricingClient.test.ts b/src/api/publicPricingClient.test.ts new file mode 100644 index 0000000..fb4c963 --- /dev/null +++ b/src/api/publicPricingClient.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "../test/testHarness"; + +import { + normalizePublicModelPrice, + normalizePublicModelPrices, +} from "./publicPricingClient"; + +describe("publicPricingClient", () => { + it("normalizes camelCase public model price payloads", () => { + expect( + normalizePublicModelPrice({ + id: 1, + modelKey: "gpt-4o", + displayName: "GPT-4o", + category: "text", + pricingType: "token", + inputPriceMills: 27, + outputPriceMills: 108, + flatPriceMills: null, + currency: "CNY", + enabled: true, + }), + ).toEqual({ + id: 1, + modelKey: "gpt-4o", + displayName: "GPT-4o", + category: "text", + pricingType: "token", + inputPriceMills: 27, + outputPriceMills: 108, + flatPriceMills: null, + currency: "CNY", + enabled: true, + createdAt: undefined, + updatedAt: undefined, + }); + }); + + it("normalizes snake_case public model price payloads inside containers", () => { + expect( + normalizePublicModelPrices({ + prices: [ + { + model_key: "deepseek-chat", + display_name: "DeepSeek Chat", + pricing_type: "token", + input_price_mills: "2", + output_price_mills: "8", + flat_price_mills: "0", + enabled: 1, + }, + { display_name: "missing key" }, + ], + }), + ).toEqual([ + { + id: undefined, + modelKey: "deepseek-chat", + displayName: "DeepSeek Chat", + category: undefined, + pricingType: "token", + inputPriceMills: 2, + outputPriceMills: 8, + flatPriceMills: 0, + currency: "CNY", + enabled: true, + createdAt: undefined, + updatedAt: undefined, + }, + ]); + }); +}); diff --git a/src/api/publicPricingClient.ts b/src/api/publicPricingClient.ts new file mode 100644 index 0000000..0934340 --- /dev/null +++ b/src/api/publicPricingClient.ts @@ -0,0 +1,141 @@ +import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import { isRecord, serverRequest } from "./serverConnection"; + +export interface PublicModelPrice { + id?: number | string; + modelKey: string; + displayName?: string; + category?: string; + pricingType?: string; + inputPriceMills: number | null; + outputPriceMills: number | null; + flatPriceMills: number | null; + currency: string; + enabled: boolean; + createdAt?: string; + updatedAt?: string; +} + +function readString( + record: Record, + keys: string[], +): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +function readNumber( + record: Record, + keys: string[], +): number | null { + for (const key of keys) { + const value = record[key]; + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : NaN; + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function readBoolean( + record: Record, + keys: string[], + fallback: boolean, +): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "enabled"].includes(normalized)) return true; + if (["0", "false", "no", "disabled"].includes(normalized)) return false; + } + } + return fallback; +} + +export function normalizePublicModelPrice( + raw: unknown, +): PublicModelPrice | null { + if (!isRecord(raw)) return null; + + const modelKey = readString(raw, ["modelKey", "model_key", "key", "model"]); + if (!modelKey) return null; + + const displayName = readString(raw, ["displayName", "display_name", "name"]); + const category = readString(raw, ["category", "type"]); + const pricingType = readString(raw, ["pricingType", "pricing_type"]); + const currency = readString(raw, ["currency"]) || "CNY"; + const createdAt = readString(raw, ["createdAt", "created_at"]); + const updatedAt = readString(raw, ["updatedAt", "updated_at"]); + const idValue = raw.id; + + return { + id: + typeof idValue === "number" || typeof idValue === "string" + ? idValue + : undefined, + modelKey, + displayName, + category, + pricingType, + inputPriceMills: readNumber(raw, ["inputPriceMills", "input_price_mills"]), + outputPriceMills: readNumber(raw, [ + "outputPriceMills", + "output_price_mills", + ]), + flatPriceMills: readNumber(raw, ["flatPriceMills", "flat_price_mills"]), + currency, + enabled: readBoolean(raw, ["enabled", "is_enabled"], true), + createdAt, + updatedAt, + }; +} + +export function normalizePublicModelPrices( + payload: unknown, +): PublicModelPrice[] { + const rawPrices = Array.isArray(payload) + ? payload + : isRecord(payload) && Array.isArray(payload.prices) + ? payload.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; +let pricesRouteMissing = false; + +export const publicPricingClient = { + async getPrices(): Promise { + if (cachedPrices) return cachedPrices; + if (pricesRouteMissing) return []; + + try { + const payload = await serverRequest("prices", { + fallbackMessage: "Model prices request failed", + }); + cachedPrices = normalizePublicModelPrices(payload); + return cachedPrices; + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + pricesRouteMissing = true; + return []; + } + throw error; + } + }, +}; diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index a7c5967..7e79e1e 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -40,6 +40,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks"; import { conversationClient, type ConversationSummary } from "../../api/conversationClient"; import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient"; +import { publicPricingClient, type PublicModelPrice } from "../../api/publicPricingClient"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import type { WebProjectSummary } from "../../types"; import { @@ -58,6 +59,8 @@ import { import { translateTaskError } from "../../utils/translateTaskError"; import { buildLocalTimeoutMessage, + FALLBACK_TEXT_TOKEN_CREDIT_RATE, + formatTextTokenCreditRule, getTaskTimeoutPolicy, isTaskLocallyTimedOut, } from "../../utils/taskLifecycle"; @@ -70,6 +73,7 @@ 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 { resolveTextTokenCreditRate } from "../../utils/modelPricing"; import { getImageQualityOptionsForContext, getDefaultImageQuality, @@ -403,9 +407,27 @@ function WorkbenchPage({ const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value)); const [chatModel, setChatModel] = useState(CHAT_MODEL_OPTIONS[0].value); + const [modelPrices, setModelPrices] = useState([]); const [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value); const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_OPTIONS[0].value); + useEffect(() => { + let cancelled = false; + + publicPricingClient + .getPrices() + .then((prices) => { + if (!cancelled) setModelPrices(prices); + }) + .catch(() => { + if (!cancelled) setModelPrices([]); + }); + + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { let cancelled = false; @@ -524,6 +546,10 @@ function WorkbenchPage({ const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality); const imageSettingsSummary = `${imageRatio} / ${imageQuality}`; + const selectedChatTokenRate = useMemo( + () => resolveTextTokenCreditRate(modelPrices, chatModel) || FALLBACK_TEXT_TOKEN_CREDIT_RATE, + [chatModel, modelPrices], + ); const billingEstimate = useMemo(() => { if (activeMode === "image") { return { @@ -552,9 +578,11 @@ function WorkbenchPage({ }; } } + const textBillingPrefix = + selectedChatTokenRate.source === "server" ? "文本计费" : "服务端价格暂不可用,按默认预估"; return { label: "按 Token 结算", - title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分", + title: `${textBillingPrefix}:${activeModel},${formatTextTokenCreditRule(selectedChatTokenRate)}`, }; }, [ activeMode, @@ -562,6 +590,7 @@ function WorkbenchPage({ activeModelValue, imageSettingsSummary, referenceItems, + selectedChatTokenRate, videoDuration, videoQuality, videoQualityLabel, diff --git a/src/utils/modelPricing.test.ts b/src/utils/modelPricing.test.ts new file mode 100644 index 0000000..e235523 --- /dev/null +++ b/src/utils/modelPricing.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "../test/testHarness"; + +import { + millsPerThousandTokensToCreditsPerMillion, + modelPriceToTextTokenCreditRate, + resolveTextTokenCreditRate, +} from "./modelPricing"; + +describe("modelPricing", () => { + it("converts backend mills per thousand tokens to credits per million tokens", () => { + expect(millsPerThousandTokensToCreditsPerMillion(27)).toBe(2_700); + expect(millsPerThousandTokensToCreditsPerMillion(108)).toBe(10_800); + }); + + it("converts a token model price row to a text token credit rate", () => { + expect( + modelPriceToTextTokenCreditRate({ + modelKey: "gpt-4o", + inputPriceMills: 27, + outputPriceMills: 108, + flatPriceMills: null, + currency: "CNY", + enabled: true, + }), + ).toEqual({ + inputCreditsPerMillion: 2_700, + outputCreditsPerMillion: 10_800, + source: "server", + modelKey: "gpt-4o", + }); + }); + + it("resolves token pricing by exact or fuzzy model key without accepting flat prices", () => { + const prices = [ + { + modelKey: "gemini-3-pro-image", + inputPriceMills: null, + outputPriceMills: null, + flatPriceMills: 200, + currency: "CNY", + enabled: true, + }, + { + modelKey: "gemini-3.1-pro", + inputPriceMills: 12, + outputPriceMills: 48, + flatPriceMills: null, + currency: "CNY", + enabled: true, + }, + ]; + + expect(resolveTextTokenCreditRate(prices, "gemini")).toEqual({ + inputCreditsPerMillion: 1_200, + outputCreditsPerMillion: 4_800, + source: "server", + modelKey: "gemini-3.1-pro", + }); + }); +}); diff --git a/src/utils/modelPricing.ts b/src/utils/modelPricing.ts new file mode 100644 index 0000000..924c5ed --- /dev/null +++ b/src/utils/modelPricing.ts @@ -0,0 +1,104 @@ +import type { PublicModelPrice } from "../api/publicPricingClient"; +import type { TextTokenCreditRate } from "./taskLifecycle"; + +const TOKENS_PER_MILLION = 1_000_000; +const BACKEND_TOKEN_PRICE_UNIT = 1_000; +const CREDITS_PER_CNY = 100; +const MILLS_PER_CNY = 1_000; +const CREDITS_PER_MILL = CREDITS_PER_CNY / MILLS_PER_CNY; + +function isUsablePrice(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function normalizeModelKey(value: string): string { + return value.trim().toLowerCase(); +} + +function compactModelKey(value: string): string { + return normalizeModelKey(value).replace(/[^a-z0-9]+/g, ""); +} + +function addCandidate( + candidates: PublicModelPrice[], + seen: Set, + price: PublicModelPrice, +): void { + const key = normalizeModelKey(price.modelKey); + if (seen.has(key)) return; + seen.add(key); + candidates.push(price); +} + +export function millsPerThousandTokensToCreditsPerMillion( + priceMills: number, +): number { + if (!isUsablePrice(priceMills)) return 0; + return ( + priceMills * + (TOKENS_PER_MILLION / BACKEND_TOKEN_PRICE_UNIT) * + CREDITS_PER_MILL + ); +} + +export function modelPriceToTextTokenCreditRate( + price: PublicModelPrice, +): TextTokenCreditRate | null { + if ( + !isUsablePrice(price.inputPriceMills) || + !isUsablePrice(price.outputPriceMills) + ) + return null; + + return { + inputCreditsPerMillion: millsPerThousandTokensToCreditsPerMillion( + price.inputPriceMills, + ), + outputCreditsPerMillion: millsPerThousandTokensToCreditsPerMillion( + price.outputPriceMills, + ), + source: "server", + modelKey: price.modelKey, + }; +} + +export function resolveTextTokenCreditRate( + prices: PublicModelPrice[], + modelKey: string | null | undefined, +): TextTokenCreditRate | null { + const normalizedTarget = normalizeModelKey(modelKey || ""); + if (!normalizedTarget) return null; + + const compactTarget = compactModelKey(normalizedTarget); + const candidates: PublicModelPrice[] = []; + const seen = new Set(); + + for (const price of prices) { + if (normalizeModelKey(price.modelKey) === normalizedTarget) { + addCandidate(candidates, seen, price); + } + } + + for (const price of prices) { + if (compactModelKey(price.modelKey) === compactTarget) { + addCandidate(candidates, seen, price); + } + } + + for (const price of prices) { + const compactPriceKey = compactModelKey(price.modelKey); + if ( + compactPriceKey.includes(compactTarget) || + compactTarget.includes(compactPriceKey) + ) { + addCandidate(candidates, seen, price); + } + } + + for (const price of candidates) { + const rate = modelPriceToTextTokenCreditRate(price); + if (rate) return rate; + } + + return null; +} diff --git a/src/utils/taskLifecycle.test.ts b/src/utils/taskLifecycle.test.ts index 6189d79..ab5c4d5 100644 --- a/src/utils/taskLifecycle.test.ts +++ b/src/utils/taskLifecycle.test.ts @@ -4,12 +4,13 @@ import { TEXT_INPUT_CREDITS_PER_MILLION, TEXT_OUTPUT_CREDITS_PER_MILLION, estimateTextTokenCredits, + formatTextTokenCreditRule, getTaskTimeoutPolicy, isTaskLocallyTimedOut, } from "./taskLifecycle"; describe("taskLifecycle", () => { - it("keeps text token billing at 1 CNY to 100 credits", () => { + it("keeps fallback text token billing at 1 CNY to 100 credits", () => { expect(TEXT_INPUT_CREDITS_PER_MILLION).toBe(200); expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500); expect( @@ -20,6 +21,23 @@ describe("taskLifecycle", () => { ).toBe(700); }); + it("estimates text billing from dynamic server pricing rates", () => { + expect( + estimateTextTokenCredits( + { + promptTokens: 1_000_000, + completionTokens: 1_000_000, + }, + { + inputCreditsPerMillion: 2_700, + outputCreditsPerMillion: 10_800, + source: "server", + modelKey: "gpt-4o", + }, + ), + ).toBe(13_500); + }); + it("ignores negative token counts when estimating text billing", () => { expect( estimateTextTokenCredits({ @@ -29,6 +47,17 @@ describe("taskLifecycle", () => { ).toBe(250); }); + it("formats text billing rules from the selected rate", () => { + expect( + formatTextTokenCreditRule({ + inputCreditsPerMillion: 2_700, + outputCreditsPerMillion: 10_800, + }), + ).toBe( + "输入 Token 每百万 2,700 积分,输出 Token 每百万 10,800 积分,实际以服务端结算为准。", + ); + }); + it("marks unstarted tasks locally timed out after submit timeout", () => { const policy = getTaskTimeoutPolicy({ kind: "image" }); diff --git a/src/utils/taskLifecycle.ts b/src/utils/taskLifecycle.ts index 757c4b2..6db0311 100644 --- a/src/utils/taskLifecycle.ts +++ b/src/utils/taskLifecycle.ts @@ -32,11 +32,24 @@ export interface TextTokenUsage { totalTokens?: number; } +export interface TextTokenCreditRate { + inputCreditsPerMillion: number; + outputCreditsPerMillion: number; + source?: "server" | "fallback"; + modelKey?: string; +} + const CREDITS_PER_CNY = 100; export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY; export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY; +export const FALLBACK_TEXT_TOKEN_CREDIT_RATE: TextTokenCreditRate = { + inputCreditsPerMillion: TEXT_INPUT_CREDITS_PER_MILLION, + outputCreditsPerMillion: TEXT_OUTPUT_CREDITS_PER_MILLION, + source: "fallback", +}; + const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = { submitTimeoutMs: 90_000, noProgressTimeoutMs: 120_000, @@ -145,18 +158,42 @@ export function getRefundHint(status: TaskRefundStatus): string { } } -export function estimateTextTokenCredits(usage: TextTokenUsage): number { - const promptTokens = Math.max(0, Number(usage.promptTokens || 0)); - const completionTokens = Math.max(0, Number(usage.completionTokens || 0)); - return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION + - (completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION; +function sanitizeCreditRate(value: number): number { + return Number.isFinite(value) && value >= 0 ? value : 0; } -export function formatTextTokenUsage(usage?: TextTokenUsage | null): string { - const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。"; +function formatCreditRate(value: number): string { + const safeValue = sanitizeCreditRate(value); + if (safeValue >= 100) return Math.round(safeValue).toLocaleString("zh-CN"); + return Number(safeValue.toFixed(4)).toString(); +} + +export function formatTextTokenCreditRule( + rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE, +): string { + return `输入 Token 每百万 ${formatCreditRate(rate.inputCreditsPerMillion)} 积分,输出 Token 每百万 ${formatCreditRate(rate.outputCreditsPerMillion)} 积分,实际以服务端结算为准。`; +} + +export function estimateTextTokenCredits( + usage: TextTokenUsage, + rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE, +): number { + const promptTokens = Math.max(0, Number(usage.promptTokens || 0)); + const completionTokens = Math.max(0, Number(usage.completionTokens || 0)); + return ( + (promptTokens / 1_000_000) * sanitizeCreditRate(rate.inputCreditsPerMillion) + + (completionTokens / 1_000_000) * sanitizeCreditRate(rate.outputCreditsPerMillion) + ); +} + +export function formatTextTokenUsage( + usage?: TextTokenUsage | null, + rate: TextTokenCreditRate = FALLBACK_TEXT_TOKEN_CREDIT_RATE, +): string { + const rule = `文本计费规则:${formatTextTokenCreditRule(rate)}`; if (!usage) return rule; const promptTokens = Math.max(0, Number(usage.promptTokens || 0)); const completionTokens = Math.max(0, Number(usage.completionTokens || 0)); - const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens }); + const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens }, rate); return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`; }