Use server prices for text billing estimates
Web Quality / verify (push) Has been cancelled

This commit is contained in:
2026-06-10 14:12:55 +08:00
parent bfb70bab26
commit d28889fd0c
7 changed files with 482 additions and 10 deletions
+72
View File
@@ -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,
},
]);
});
});
+141
View File
@@ -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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<PublicModelPrice[]> {
if (cachedPrices) return cachedPrices;
if (pricesRouteMissing) return [];
try {
const payload = await serverRequest<unknown>("prices", {
fallbackMessage: "Model prices request failed",
});
cachedPrices = normalizePublicModelPrices(payload);
return cachedPrices;
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
pricesRouteMissing = true;
return [];
}
throw error;
}
},
};
+30 -1
View File
@@ -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<PublicModelPrice[]>([]);
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,
+60
View File
@@ -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",
});
});
});
+104
View File
@@ -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<string>,
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<string>();
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;
}
+30 -1
View File
@@ -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" });
+45 -8
View File
@@ -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}`;
}