Codex/canvas pricing cleanup #33
@@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -40,6 +40,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
|||||||
|
|
||||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||||
|
import { publicPricingClient, type PublicModelPrice } from "../../api/publicPricingClient";
|
||||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||||
import type { WebProjectSummary } from "../../types";
|
import type { WebProjectSummary } from "../../types";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +59,8 @@ import {
|
|||||||
import { translateTaskError } from "../../utils/translateTaskError";
|
import { translateTaskError } from "../../utils/translateTaskError";
|
||||||
import {
|
import {
|
||||||
buildLocalTimeoutMessage,
|
buildLocalTimeoutMessage,
|
||||||
|
FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||||
|
formatTextTokenCreditRule,
|
||||||
getTaskTimeoutPolicy,
|
getTaskTimeoutPolicy,
|
||||||
isTaskLocallyTimedOut,
|
isTaskLocallyTimedOut,
|
||||||
} from "../../utils/taskLifecycle";
|
} from "../../utils/taskLifecycle";
|
||||||
@@ -70,6 +73,7 @@ import { isViduModel } from "../../utils/viduRouting";
|
|||||||
import { isPixverseModel } from "../../utils/pixverseRouting";
|
import { isPixverseModel } from "../../utils/pixverseRouting";
|
||||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||||
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||||
|
import { resolveTextTokenCreditRate } from "../../utils/modelPricing";
|
||||||
import {
|
import {
|
||||||
getImageQualityOptionsForContext,
|
getImageQualityOptionsForContext,
|
||||||
getDefaultImageQuality,
|
getDefaultImageQuality,
|
||||||
@@ -403,9 +407,27 @@ function WorkbenchPage({
|
|||||||
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
|
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
|
||||||
|
|
||||||
const [chatModel, setChatModel] = useState(CHAT_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 [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
|
||||||
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -524,6 +546,10 @@ function WorkbenchPage({
|
|||||||
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
||||||
|
|
||||||
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
||||||
|
const selectedChatTokenRate = useMemo(
|
||||||
|
() => resolveTextTokenCreditRate(modelPrices, chatModel) || FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||||
|
[chatModel, modelPrices],
|
||||||
|
);
|
||||||
const billingEstimate = useMemo(() => {
|
const billingEstimate = useMemo(() => {
|
||||||
if (activeMode === "image") {
|
if (activeMode === "image") {
|
||||||
return {
|
return {
|
||||||
@@ -552,9 +578,11 @@ function WorkbenchPage({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const textBillingPrefix =
|
||||||
|
selectedChatTokenRate.source === "server" ? "文本计费" : "服务端价格暂不可用,按默认预估";
|
||||||
return {
|
return {
|
||||||
label: "按 Token 结算",
|
label: "按 Token 结算",
|
||||||
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
|
title: `${textBillingPrefix}:${activeModel},${formatTextTokenCreditRule(selectedChatTokenRate)}`,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeMode,
|
activeMode,
|
||||||
@@ -562,6 +590,7 @@ function WorkbenchPage({
|
|||||||
activeModelValue,
|
activeModelValue,
|
||||||
imageSettingsSummary,
|
imageSettingsSummary,
|
||||||
referenceItems,
|
referenceItems,
|
||||||
|
selectedChatTokenRate,
|
||||||
videoDuration,
|
videoDuration,
|
||||||
videoQuality,
|
videoQuality,
|
||||||
videoQualityLabel,
|
videoQualityLabel,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import {
|
|||||||
TEXT_INPUT_CREDITS_PER_MILLION,
|
TEXT_INPUT_CREDITS_PER_MILLION,
|
||||||
TEXT_OUTPUT_CREDITS_PER_MILLION,
|
TEXT_OUTPUT_CREDITS_PER_MILLION,
|
||||||
estimateTextTokenCredits,
|
estimateTextTokenCredits,
|
||||||
|
formatTextTokenCreditRule,
|
||||||
getTaskTimeoutPolicy,
|
getTaskTimeoutPolicy,
|
||||||
isTaskLocallyTimedOut,
|
isTaskLocallyTimedOut,
|
||||||
} from "./taskLifecycle";
|
} from "./taskLifecycle";
|
||||||
|
|
||||||
describe("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_INPUT_CREDITS_PER_MILLION).toBe(200);
|
||||||
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
|
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
|
||||||
expect(
|
expect(
|
||||||
@@ -20,6 +21,23 @@ describe("taskLifecycle", () => {
|
|||||||
).toBe(700);
|
).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", () => {
|
it("ignores negative token counts when estimating text billing", () => {
|
||||||
expect(
|
expect(
|
||||||
estimateTextTokenCredits({
|
estimateTextTokenCredits({
|
||||||
@@ -29,6 +47,17 @@ describe("taskLifecycle", () => {
|
|||||||
).toBe(250);
|
).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", () => {
|
it("marks unstarted tasks locally timed out after submit timeout", () => {
|
||||||
const policy = getTaskTimeoutPolicy({ kind: "image" });
|
const policy = getTaskTimeoutPolicy({ kind: "image" });
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,24 @@ export interface TextTokenUsage {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextTokenCreditRate {
|
||||||
|
inputCreditsPerMillion: number;
|
||||||
|
outputCreditsPerMillion: number;
|
||||||
|
source?: "server" | "fallback";
|
||||||
|
modelKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CREDITS_PER_CNY = 100;
|
const CREDITS_PER_CNY = 100;
|
||||||
|
|
||||||
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
|
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
|
||||||
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * 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 = {
|
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||||
submitTimeoutMs: 90_000,
|
submitTimeoutMs: 90_000,
|
||||||
noProgressTimeoutMs: 120_000,
|
noProgressTimeoutMs: 120_000,
|
||||||
@@ -145,18 +158,42 @@ export function getRefundHint(status: TaskRefundStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
|
function sanitizeCreditRate(value: number): number {
|
||||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
return Number.isFinite(value) && value >= 0 ? value : 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
|
function formatCreditRate(value: number): string {
|
||||||
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
|
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;
|
if (!usage) return rule;
|
||||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
||||||
const completionTokens = Math.max(0, Number(usage.completionTokens || 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}`;
|
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user