178a2c47da
Centralize timeout policies, stall detection, and error classification for image/video/text generation tasks. Improve ecommerce OSS upload flow and add script evaluation enhancements. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
161 lines
5.7 KiB
TypeScript
161 lines
5.7 KiB
TypeScript
import { classifyTaskError, type TaskErrorCategory } from "./translateTaskError";
|
|
|
|
export type GenerationLifecycleStatus =
|
|
| "creating"
|
|
| "queued"
|
|
| "running"
|
|
| "stopping"
|
|
| "failed"
|
|
| "completed"
|
|
| "local_timeout";
|
|
|
|
export type TaskRefundStatus = "not_charged" | "pending_refund" | "refunded" | "manual_review" | "unknown";
|
|
|
|
export interface TaskTimeoutPolicy {
|
|
submitTimeoutMs: number;
|
|
noProgressTimeoutMs: number;
|
|
maxRuntimeMs: number;
|
|
}
|
|
|
|
export interface TaskFailureInfo {
|
|
category: TaskErrorCategory;
|
|
message: string;
|
|
actionLabel: string;
|
|
retryable: boolean;
|
|
refundStatus: TaskRefundStatus;
|
|
refundHint: string;
|
|
}
|
|
|
|
export interface TextTokenUsage {
|
|
promptTokens?: number;
|
|
completionTokens?: number;
|
|
totalTokens?: number;
|
|
}
|
|
|
|
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
|
|
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
|
|
|
|
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
|
submitTimeoutMs: 90_000,
|
|
noProgressTimeoutMs: 120_000,
|
|
maxRuntimeMs: 10 * 60_000,
|
|
};
|
|
|
|
const VIDEO_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
|
submitTimeoutMs: 120_000,
|
|
noProgressTimeoutMs: 120_000,
|
|
maxRuntimeMs: 20 * 60_000,
|
|
};
|
|
|
|
const VIDEO_LONG_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
|
submitTimeoutMs: 120_000,
|
|
noProgressTimeoutMs: 180_000,
|
|
maxRuntimeMs: 30 * 60_000,
|
|
};
|
|
|
|
const VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
|
submitTimeoutMs: 120_000,
|
|
noProgressTimeoutMs: 180_000,
|
|
maxRuntimeMs: 15 * 60_000,
|
|
};
|
|
|
|
const TEXT_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
|
submitTimeoutMs: 30_000,
|
|
noProgressTimeoutMs: 60_000,
|
|
maxRuntimeMs: 5 * 60_000,
|
|
};
|
|
|
|
export function getTaskTimeoutPolicy(input: {
|
|
kind?: "image" | "video" | "text";
|
|
model?: string | null;
|
|
operation?: string | null;
|
|
}): TaskTimeoutPolicy {
|
|
if (input.operation === "video-super-resolution") return VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY;
|
|
if (input.kind === "image") return IMAGE_TIMEOUT_POLICY;
|
|
if (input.kind === "text") return TEXT_TIMEOUT_POLICY;
|
|
const model = String(input.model || "").toLowerCase();
|
|
if (/kling|wan|veo|sora|hailuo|vidu|pixverse|happyhorse/.test(model)) return VIDEO_LONG_TIMEOUT_POLICY;
|
|
return VIDEO_TIMEOUT_POLICY;
|
|
}
|
|
|
|
export function isTaskLocallyTimedOut(input: {
|
|
startedAt: number;
|
|
lastProgressAt: number;
|
|
now?: number;
|
|
policy: TaskTimeoutPolicy;
|
|
progress?: number;
|
|
}): "no_progress" | "max_runtime" | null {
|
|
const now = input.now || Date.now();
|
|
const progress = Number(input.progress || 0);
|
|
if (now - input.startedAt >= input.policy.maxRuntimeMs) return "max_runtime";
|
|
if (progress > 0 && progress < 100 && now - input.lastProgressAt >= input.policy.noProgressTimeoutMs) {
|
|
return "no_progress";
|
|
}
|
|
if (progress <= 0 && now - input.startedAt >= input.policy.submitTimeoutMs) return "no_progress";
|
|
return null;
|
|
}
|
|
|
|
export function buildLocalTimeoutMessage(kind: "image" | "video" | "text" = "video"): string {
|
|
if (kind === "text") {
|
|
return "本地等待已超时,已停止前端动画。若服务端稍后返回,请以会话记录和积分流水为准。";
|
|
}
|
|
const label = kind === "image" ? "图片" : "视频";
|
|
return `${label}任务长时间没有进展,已停止本地等待并释放前端占用。服务端任务仍可能稍后完成,请到任务历史或资产页查看结果;如已扣费,系统会在失败结算后按积分流水退回。`;
|
|
}
|
|
|
|
export function buildTaskFailureInfo(
|
|
error: string | undefined | null,
|
|
options: { refundStatus?: TaskRefundStatus; charged?: boolean; submitted?: boolean } = {},
|
|
): TaskFailureInfo {
|
|
const classified = classifyTaskError(error);
|
|
const submitted = options.submitted !== false;
|
|
const refundStatus: TaskRefundStatus =
|
|
options.refundStatus ||
|
|
(submitted
|
|
? classified.category === "insufficient_balance" || classified.category === "auth_failure"
|
|
? "not_charged"
|
|
: "unknown"
|
|
: "not_charged");
|
|
|
|
const refundHint = getRefundHint(refundStatus);
|
|
return {
|
|
category: classified.category,
|
|
message: `${classified.message}${refundHint ? `\n\n${refundHint}` : ""}`,
|
|
actionLabel: classified.action,
|
|
retryable: !["auth_failure", "insufficient_balance", "content_policy"].includes(classified.category),
|
|
refundStatus,
|
|
refundHint,
|
|
};
|
|
}
|
|
|
|
export function getRefundHint(status: TaskRefundStatus): string {
|
|
switch (status) {
|
|
case "not_charged":
|
|
return "提交未进入扣费结算,未产生积分消耗。";
|
|
case "pending_refund":
|
|
return "任务已失败,若已扣费,系统会自动退回,请以积分流水为准。";
|
|
case "refunded":
|
|
return "失败扣费已退回,请在积分流水中核对。";
|
|
case "manual_review":
|
|
return "退款状态需要人工核对,请联系管理员并提供任务 ID。";
|
|
default:
|
|
return "如已扣费,系统将在任务失败后自动退回;请以积分流水为准。";
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
|
|
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
|
|
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 });
|
|
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
|
|
}
|