Codex/canvas pricing cleanup #33

Merged
stringadmin merged 3 commits from codex/canvas-pricing-cleanup into master 2026-06-10 07:35:46 +00:00
12 changed files with 879 additions and 63 deletions
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "../test/testHarness";
import {
normalizeEnterpriseVideoPricingConfig,
normalizePublicModelPrice,
normalizePublicModelPrices,
normalizePublicPricingPayload,
} 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,
},
]);
});
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);
});
});
+236
View File
@@ -0,0 +1,236 @@
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import { isRecord, serverRequest } from "./serverConnection";
import type { EnterpriseVideoPricingConfig, EnterpriseVideoPricingRule } from "../utils/enterpriseVideoPolicy";
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;
}
export interface PublicPricingPayload {
modelPrices: PublicModelPrice[];
enterpriseVideoPricing: EnterpriseVideoPricingConfig | null;
}
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;
}
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 {
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.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));
}
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 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",
});
cachedPricing = normalizePublicPricingPayload(payload);
return cachedPricing;
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
pricesRouteMissing = true;
return { modelPrices: [], enterpriseVideoPricing: null };
}
throw error;
}
},
async getPrices(): Promise<PublicModelPrice[]> {
const pricing = await publicPricingClient.getPricing();
return pricing.modelPrices;
},
};
+30 -8
View File
@@ -371,12 +371,19 @@ function CanvasPage({
const textNodeIdRef = useRef(9); const textNodeIdRef = useRef(9);
const imageNodeIdRef = useRef(1); const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1); const videoNodeIdRef = useRef(1);
const objectUrlsRef = useRef(new Set<string>());
const trackObjectUrl = (file: Blob) => {
const url = URL.createObjectURL(file);
objectUrlsRef.current.add(url);
return url;
};
const { pushSnapshot, undo, redo } = useCanvasHistory(); const { pushSnapshot, undo, redo } = useCanvasHistory();
const { const {
textGenerationState, imageGenerationState, videoGenerationState, textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast, generationToast, setGenerationToast,
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
imageGenerationAbortRef, videoGenerationAbortRef,
canvasGenKeepaliveRestoredRef, canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus, setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState, restoreKeepaliveTasks, resetGenerationState,
@@ -527,6 +534,7 @@ function CanvasPage({
const autoSaveStatusTimerRef = useRef<number | null>(null); const autoSaveStatusTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
const objectUrls = objectUrlsRef.current;
return () => { return () => {
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current); if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current); if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
@@ -535,6 +543,8 @@ function CanvasPage({
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) { if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
} }
objectUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrls.clear();
}; };
}, []); }, []);
@@ -1691,12 +1701,15 @@ function CanvasPage({
const quality = resolveImageQuality(model, imageNode.imageSize || ""); const quality = resolveImageQuality(model, imageNode.imageSize || "");
imageGenerationInFlightRef.current.add(nodeId); imageGenerationInFlightRef.current.add(nodeId);
const abortRef = { current: false };
imageGenerationAbortRef.current.set(nodeId, abortRef);
setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 }); setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
setGenerationToast("图片正在生成"); setGenerationToast("图片正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null; let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try { try {
const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode); const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
if (abortRef.current) return;
const taskInput: CreatePreviewTaskInput = { const taskInput: CreatePreviewTaskInput = {
title: imageNode.title || "图片节点生成", title: imageNode.title || "图片节点生成",
type: "image", type: "image",
@@ -1732,7 +1745,8 @@ function CanvasPage({
? "图片生成完成" ? "图片生成完成"
: "图片生成失败"; : "图片生成失败";
setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
})); }, abortRef));
if (abortRef.current || !outputUrl) return;
setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 }); setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
removeCanvasGenKeepalive(task.id); removeCanvasGenKeepalive(task.id);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
@@ -1794,13 +1808,15 @@ function CanvasPage({
); );
} }
} catch (error) { } catch (error) {
if (abortRef.current) return;
setImageGenerationStatus(nodeId, { setImageGenerationStatus(nodeId, {
status: "error", status: "error",
message: error instanceof Error ? error.message : "图片生成失败", message: error instanceof Error ? error.message : "图片生成失败",
}); });
} finally { } finally {
imageGenerationInFlightRef.current.delete(nodeId); imageGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id); imageGenerationAbortRef.current.delete(nodeId);
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
} }
}; };
@@ -1843,12 +1859,15 @@ function CanvasPage({
const duration = Number(videoNode.duration) || 4; const duration = Number(videoNode.duration) || 4;
videoGenerationInFlightRef.current.add(nodeId); videoGenerationInFlightRef.current.add(nodeId);
const abortRef = { current: false };
videoGenerationAbortRef.current.set(nodeId, abortRef);
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 }); setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
setGenerationToast("视频正在生成"); setGenerationToast("视频正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null; let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try { try {
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId); const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
if (abortRef.current) return;
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) { if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
throw new Error("图生视频需要先连接至少一个可用的图片节点"); throw new Error("图生视频需要先连接至少一个可用的图片节点");
} }
@@ -1892,7 +1911,8 @@ function CanvasPage({
? "视频生成完成" ? "视频生成完成"
: "视频生成失败"; : "视频生成失败";
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
})); }, abortRef));
if (abortRef.current || !outputUrl) return;
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 }); setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
removeCanvasGenKeepalive(taskId); removeCanvasGenKeepalive(taskId);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
@@ -1948,13 +1968,15 @@ function CanvasPage({
); );
} }
} catch (error) { } catch (error) {
if (abortRef.current) return;
setVideoGenerationStatus(nodeId, { setVideoGenerationStatus(nodeId, {
status: "error", status: "error",
message: error instanceof Error ? error.message : "视频生成失败", message: error instanceof Error ? error.message : "视频生成失败",
}); });
} finally { } finally {
videoGenerationInFlightRef.current.delete(nodeId); videoGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id); videoGenerationAbortRef.current.delete(nodeId);
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
} }
}; };
@@ -1965,7 +1987,7 @@ function CanvasPage({
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
if (!file) return; if (!file) return;
const imageUrl = URL.createObjectURL(file); const imageUrl = trackObjectUrl(file);
if (pendingImageToImageNodeId) { if (pendingImageToImageNodeId) {
const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId); const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
if (sourceNode) { if (sourceNode) {
@@ -2047,7 +2069,7 @@ function CanvasPage({
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
for (const file of files) { for (const file of files) {
const imageUrl = URL.createObjectURL(file); const imageUrl = trackObjectUrl(file);
addImageNode(imageUrl, file.name, { addImageNode(imageUrl, file.name, {
x: dropPosition.x + offsetX, x: dropPosition.x + offsetX,
y: dropPosition.y + offsetY, y: dropPosition.y + offsetY,
@@ -2103,7 +2125,7 @@ function CanvasPage({
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
for (const file of files) { for (const file of files) {
const imageUrl = URL.createObjectURL(file); const imageUrl = trackObjectUrl(file);
addImageNode(imageUrl, file.name, { addImageNode(imageUrl, file.name, {
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX, x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
y: sourceNode.position.y + offsetY, y: sourceNode.position.y + offsetY,
@@ -5279,7 +5301,7 @@ function CanvasPage({
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setAssetCoverUrl(URL.createObjectURL(file)); setAssetCoverUrl(trackObjectUrl(file));
setCoverSourceOpen(false); setCoverSourceOpen(false);
}} }}
/> />
+14 -2
View File
@@ -252,28 +252,40 @@ export function blobToDataUrl(blob: Blob) {
}); });
} }
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { export async function waitForImageTaskResult(
taskId: string,
onStatus?: (status: AiTaskStatus) => void,
abortRef?: { current: boolean },
) {
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
kind: "image", kind: "image",
abortRef,
onProgress: (e) => { onProgress: (e) => {
if (onStatus) { if (onStatus) {
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
} }
}, },
}); });
if (abortRef?.current) return "";
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试"); if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
return resultUrl; return resultUrl;
} }
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { export async function waitForVideoTaskResult(
taskId: string,
onStatus?: (status: AiTaskStatus) => void,
abortRef?: { current: boolean },
) {
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
kind: "video", kind: "video",
abortRef,
onProgress: (e) => { onProgress: (e) => {
if (onStatus) { if (onStatus) {
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
} }
}, },
}); });
if (abortRef?.current) return "";
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试"); if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
return resultUrl; return resultUrl;
} }
+37 -8
View File
@@ -1,4 +1,4 @@
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react"; import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import type { import type {
CanvasImageGenerationState, CanvasImageGenerationState,
CanvasImageNode, CanvasImageNode,
@@ -66,6 +66,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
const videoGenerationInFlightRef = useRef(new Set<string>()); const videoGenerationInFlightRef = useRef(new Set<string>());
const textGenerationInFlightRef = useRef(new Set<string>()); const textGenerationInFlightRef = useRef(new Set<string>());
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>()); const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
const imageGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const videoGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const canvasGenKeepaliveRestoredRef = useRef(false); const canvasGenKeepaliveRestoredRef = useRef(false);
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => { const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
@@ -80,6 +82,15 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
setVideoGenerationState((current) => ({ ...current, [nodeId]: state })); setVideoGenerationState((current) => ({ ...current, [nodeId]: state }));
}; };
const abortAllGenerationPollers = useCallback(() => {
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
textGenerationAbortControllersRef.current.clear();
imageGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
imageGenerationAbortRef.current.clear();
videoGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
videoGenerationAbortRef.current.clear();
}, []);
// Toast auto-dismiss // Toast auto-dismiss
useEffect(() => { useEffect(() => {
if (!generationToast) return undefined; if (!generationToast) return undefined;
@@ -103,11 +114,14 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
} }
if (entry.nodeKind === "image") { if (entry.nodeKind === "image") {
imageGenerationInFlightRef.current.add(entry.nodeId); imageGenerationInFlightRef.current.add(entry.nodeId);
const abortRef = { current: false };
imageGenerationAbortRef.current.set(entry.nodeId, abortRef);
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 }); setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
void waitForImageTaskResult(entry.taskId, (status) => { void waitForImageTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress }); setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
}).then(async (outputUrl) => { }, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 }); setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({ const ref = createCanvasAssetRefFromGeneratedResult({
@@ -128,18 +142,23 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
)); ));
} }
}).catch(() => { }).catch(() => {
if (abortRef.current) return;
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" }); setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
}).finally(() => { }).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId); imageGenerationInFlightRef.current.delete(entry.nodeId);
imageGenerationAbortRef.current.delete(entry.nodeId);
}); });
} else if (entry.nodeKind === "video") { } else if (entry.nodeKind === "video") {
videoGenerationInFlightRef.current.add(entry.nodeId); videoGenerationInFlightRef.current.add(entry.nodeId);
const abortRef = { current: false };
videoGenerationAbortRef.current.set(entry.nodeId, abortRef);
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 }); setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
void waitForVideoTaskResult(entry.taskId, (status) => { void waitForVideoTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress }); setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
}).then(async (outputUrl) => { }, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 }); setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({ const ref = createCanvasAssetRefFromGeneratedResult({
@@ -160,18 +179,19 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
)); ));
} }
}).catch(() => { }).catch(() => {
if (abortRef.current) return;
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" }); setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => { }).finally(() => {
videoGenerationInFlightRef.current.delete(entry.nodeId); videoGenerationInFlightRef.current.delete(entry.nodeId);
videoGenerationAbortRef.current.delete(entry.nodeId);
}); });
} }
} }
}; };
const resetGenerationState = () => { const resetGenerationState = () => {
textGenerationAbortControllersRef.current.forEach((c) => c.abort()); abortAllGenerationPollers();
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear(); textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear(); imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear(); videoGenerationInFlightRef.current.clear();
@@ -180,11 +200,18 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
setVideoGenerationState({}); setVideoGenerationState({});
}; };
// Stop all in-flight front-end polling/setState when the canvas unmounts (route change).
// Keepalive records are intentionally preserved so restoreKeepaliveTasks can resume on return.
useEffect(() => {
return () => {
abortAllGenerationPollers();
};
}, [abortAllGenerationPollers]);
useEffect(() => { useEffect(() => {
const handlePageHide = () => { const handlePageHide = () => {
cancelCanvasGenKeepaliveOnUnload(); cancelCanvasGenKeepaliveOnUnload();
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort()); abortAllGenerationPollers();
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear(); textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear(); imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear(); videoGenerationInFlightRef.current.clear();
@@ -202,7 +229,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
window.removeEventListener("pagehide", handlePageHide); window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline); window.removeEventListener("online", handleOnline);
}; };
}, []); }, [abortAllGenerationPollers]);
return { return {
textGenerationState, textGenerationState,
@@ -214,6 +241,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
videoGenerationInFlightRef, videoGenerationInFlightRef,
textGenerationInFlightRef, textGenerationInFlightRef,
textGenerationAbortControllersRef, textGenerationAbortControllersRef,
imageGenerationAbortRef,
videoGenerationAbortRef,
canvasGenKeepaliveRestoredRef, canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setTextGenerationStatus,
setImageGenerationStatus, setImageGenerationStatus,
+42 -3
View File
@@ -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";
@@ -69,7 +72,12 @@ import {
import { isViduModel } from "../../utils/viduRouting"; 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,
type EnterpriseVideoPricingConfig,
} from "../../utils/enterpriseVideoPolicy";
import { resolveTextTokenCreditRate } from "../../utils/modelPricing";
import { import {
getImageQualityOptionsForContext, getImageQualityOptionsForContext,
getDefaultImageQuality, getDefaultImageQuality,
@@ -403,9 +411,32 @@ 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 [enterpriseVideoPricing, setEnterpriseVideoPricing] = useState<EnterpriseVideoPricingConfig | null>(null);
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
.getPricing()
.then((pricing) => {
if (cancelled) return;
setModelPrices(pricing.modelPrices);
setEnterpriseVideoPricing(pricing.enterpriseVideoPricing);
})
.catch(() => {
if (cancelled) return;
setModelPrices([]);
setEnterpriseVideoPricing(null);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -524,6 +555,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 {
@@ -540,7 +575,7 @@ function WorkbenchPage({
durationSeconds, durationSeconds,
muted: false, muted: false,
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"), hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
}); }, enterpriseVideoPricing || undefined);
return { return {
label: `预计 ${formatCreditValue(credits)} 积分`, label: `预计 ${formatCreditValue(credits)} 积分`,
title: `${activeModel}${videoQualityLabel}${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`, title: `${activeModel}${videoQualityLabel}${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
@@ -552,16 +587,20 @@ function WorkbenchPage({
}; };
} }
} }
const textBillingPrefix =
selectedChatTokenRate.source === "server" ? "文本计费" : "服务端价格暂不可用,按默认预估";
return { return {
label: "按 Token 结算", label: "按 Token 结算",
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分", title: `${textBillingPrefix}${activeModel}${formatTextTokenCreditRule(selectedChatTokenRate)}`,
}; };
}, [ }, [
activeMode, activeMode,
activeModel, activeModel,
activeModelValue, activeModelValue,
imageSettingsSummary, imageSettingsSummary,
enterpriseVideoPricing,
referenceItems, referenceItems,
selectedChatTokenRate,
videoDuration, videoDuration,
videoQuality, videoQuality,
videoQualityLabel, videoQualityLabel,
+37
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "../test/testHarness";
import { import {
calculateEnterpriseVideoCredits, calculateEnterpriseVideoCredits,
type EnterpriseVideoPricingConfig,
getEnterpriseVideoCreditRate, getEnterpriseVideoCreditRate,
normalizeEnterpriseResolution, normalizeEnterpriseResolution,
} from "./enterpriseVideoPolicy"; } from "./enterpriseVideoPolicy";
@@ -45,4 +46,40 @@ describe("enterpriseVideoPolicy", () => {
}), }),
).toBe(1); ).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; 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" { export function normalizeEnterpriseResolution(value: string): "720P" | "1080P" {
return String(value || "").toUpperCase() === "720P" ? "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 resolution = normalizeEnterpriseResolution(input.resolution);
const model = String(input.model || "").toLowerCase(); 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")) { if (rule) {
return resolution === "720P" ? 0.72 : 1.28; const rate = rule.rates[resolution] ?? rule.rates[fallbackResolution];
} if (Number.isFinite(rate) && rate >= 0) return rate;
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;
} }
throw new Error(`Unsupported enterprise video model: ${input.model}`); 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)); 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));
} }
+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_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" });
+45 -8
View File
@@ -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}`;
} }