159 lines
3.8 KiB
TypeScript
159 lines
3.8 KiB
TypeScript
import sharp from "sharp";
|
|
import { ensurePublicImageUrl } from "@/lib/realtime/image-input";
|
|
import { collectFalImageUrls, normalizeFalCredentials } from "@/lib/realtime/fal-shared";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 25_000;
|
|
|
|
export type FalCapabilityCode =
|
|
| "SUPPORTED"
|
|
| "AUTH_FAILED"
|
|
| "MODEL_INVALID"
|
|
| "UPSTREAM_ERROR";
|
|
|
|
export type FalModelProbeResult = {
|
|
supported: boolean;
|
|
code: FalCapabilityCode;
|
|
message: string;
|
|
status?: number;
|
|
};
|
|
|
|
type FalProbeParams = {
|
|
apiKey: string;
|
|
modelId?: string;
|
|
timeoutMs?: number;
|
|
};
|
|
|
|
let cachedProbeImageUrl: string | null = null;
|
|
|
|
async function getProbeImageUrl() {
|
|
if (cachedProbeImageUrl) return cachedProbeImageUrl;
|
|
|
|
const buffer = await sharp({
|
|
create: {
|
|
width: 64,
|
|
height: 64,
|
|
channels: 3,
|
|
background: { r: 255, g: 255, b: 255 },
|
|
},
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
|
|
cachedProbeImageUrl = await ensurePublicImageUrl({
|
|
imageUrl: `data:image/png;base64,${buffer.toString("base64")}`,
|
|
prefix: "realtime-fal-probe",
|
|
});
|
|
return cachedProbeImageUrl;
|
|
}
|
|
|
|
function toErrorMessage(error: unknown, fallback: string) {
|
|
if (!error) return fallback;
|
|
if (typeof error === "string") return error.trim() || fallback;
|
|
if (typeof error === "object") {
|
|
const anyError = error as Record<string, unknown>;
|
|
const message = String(
|
|
anyError.message || anyError.error || anyError.detail || anyError.reason || ""
|
|
).trim();
|
|
if (message) return message;
|
|
}
|
|
if (error instanceof Error && error.message.trim()) {
|
|
return error.message.trim();
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function isModelInvalid(message: string) {
|
|
const normalized = message.toLowerCase();
|
|
return (
|
|
normalized.includes("model") &&
|
|
(normalized.includes("not found") ||
|
|
normalized.includes("invalid") ||
|
|
normalized.includes("unknown") ||
|
|
normalized.includes("does not exist"))
|
|
);
|
|
}
|
|
|
|
export async function probeFalModelSupport(
|
|
params: FalProbeParams
|
|
): Promise<FalModelProbeResult> {
|
|
const apiKey = normalizeFalCredentials(params.apiKey);
|
|
const modelId = String(params.modelId || "").trim();
|
|
|
|
if (!apiKey) {
|
|
return {
|
|
supported: false,
|
|
code: "AUTH_FAILED",
|
|
message: "缺少 FAL API Key。",
|
|
};
|
|
}
|
|
|
|
if (!modelId) {
|
|
return {
|
|
supported: false,
|
|
code: "MODEL_INVALID",
|
|
message: "缺少 FAL 模型 ID。",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const imageUrl = await getProbeImageUrl();
|
|
const fal = await import("@fal-ai/serverless-client");
|
|
fal.config({
|
|
credentials: apiKey,
|
|
});
|
|
|
|
const result = await fal.subscribe(modelId, {
|
|
input: {
|
|
prompt: "white square photo",
|
|
image_urls: [imageUrl],
|
|
},
|
|
logs: false,
|
|
timeout: Math.max(5_000, Number(params.timeoutMs || DEFAULT_TIMEOUT_MS)),
|
|
});
|
|
|
|
const urls = collectFalImageUrls(result);
|
|
if (urls.length > 0) {
|
|
return {
|
|
supported: true,
|
|
code: "SUPPORTED",
|
|
message: "模型可用。",
|
|
status: 200,
|
|
};
|
|
}
|
|
|
|
return {
|
|
supported: false,
|
|
code: "UPSTREAM_ERROR",
|
|
message: "FAL 探针已返回,但未发现可用图片输出。",
|
|
status: 502,
|
|
};
|
|
} catch (error) {
|
|
const message = toErrorMessage(error, "FAL 探针请求失败。");
|
|
|
|
if (/401|403|unauthorized|forbidden|invalid api key|authentication/i.test(message)) {
|
|
return {
|
|
supported: false,
|
|
code: "AUTH_FAILED",
|
|
message: "FAL API Key 无效或无权限,请检查后重试。",
|
|
status: 401,
|
|
};
|
|
}
|
|
|
|
if (isModelInvalid(message)) {
|
|
return {
|
|
supported: false,
|
|
code: "MODEL_INVALID",
|
|
message: `模型 ${modelId} 不存在或不可用,请更换模型后重试。`,
|
|
status: 404,
|
|
};
|
|
}
|
|
|
|
return {
|
|
supported: false,
|
|
code: "UPSTREAM_ERROR",
|
|
message,
|
|
status: 502,
|
|
};
|
|
}
|
|
}
|