feat(realtime-canvas): migrate provider to BFL flux.2
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-06 10:18:56 +08:00
parent 795fcff90f
commit b5fdbd7c10
8 changed files with 617 additions and 922 deletions

View File

@ -102,8 +102,8 @@ import {
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
const fetcher = (url: string) => fetch(url).then((res) => res.json()); const fetcher = (url: string) => fetch(url).then((res) => res.json());
const DEFAULT_RUNWARE_REALTIME_MODEL = "runware:101@1"; const DEFAULT_BFL_REALTIME_MODEL = "flux-2-klein-4b";
const DEFAULT_RUNWARE_STYLIZE_MODEL = "runware:106@1"; const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
function createEmptyCanvasSkillTag(): CanvasSkillTag { function createEmptyCanvasSkillTag(): CanvasSkillTag {
return { return {
@ -810,11 +810,9 @@ export function AdminSettingsClient() {
// 实时画布API配置状态 // 实时画布API配置状态
const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
enabled: true, enabled: true,
falApiKey: "", bflApiKey: "",
runwareModel: DEFAULT_RUNWARE_REALTIME_MODEL, bflBaseUrl: DEFAULT_BFL_BASE_URL,
runwareStylizeModel: DEFAULT_RUNWARE_STYLIZE_MODEL, bflModel: DEFAULT_BFL_REALTIME_MODEL,
runwareControlModel: "runware:25@1",
runwareControlPreprocessor: "canny" as "canny" | "lineart" | "depth",
renderMode: "balanced" as "strict-structure" | "balanced" | "stylize", renderMode: "balanced" as "strict-structure" | "balanced" | "stylize",
throttleInterval: 100, throttleInterval: 100,
maxBuffering: 2, maxBuffering: 2,
@ -970,12 +968,12 @@ export function AdminSettingsClient() {
if (realtimeCanvasConfigData?.setting?.value) { if (realtimeCanvasConfigData?.setting?.value) {
const config = realtimeCanvasConfigData.setting.value as { const config = realtimeCanvasConfigData.setting.value as {
enabled?: boolean; enabled?: boolean;
bflApiKey?: string;
bflBaseUrl?: string;
bflModel?: string;
runwareApiKey?: string; runwareApiKey?: string;
falApiKey?: string; falApiKey?: string;
runwareModel?: string; runwareModel?: string;
runwareStylizeModel?: string;
runwareControlModel?: string;
runwareControlPreprocessor?: "canny" | "lineart" | "depth";
renderMode?: "strict-structure" | "balanced" | "stylize"; renderMode?: "strict-structure" | "balanced" | "stylize";
throttleInterval?: number; throttleInterval?: number;
maxBuffering?: number; maxBuffering?: number;
@ -990,15 +988,9 @@ export function AdminSettingsClient() {
}; };
setRealtimeCanvasConfig({ setRealtimeCanvasConfig({
enabled: config.enabled ?? true, enabled: config.enabled ?? true,
falApiKey: config.runwareApiKey || config.falApiKey || "", bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "",
runwareModel: config.runwareModel || DEFAULT_RUNWARE_REALTIME_MODEL, bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL,
runwareStylizeModel: config.runwareStylizeModel || DEFAULT_RUNWARE_STYLIZE_MODEL, bflModel: config.bflModel || config.runwareModel || DEFAULT_BFL_REALTIME_MODEL,
runwareControlModel: config.runwareControlModel || "runware:25@1",
runwareControlPreprocessor:
config.runwareControlPreprocessor === "lineart" ||
config.runwareControlPreprocessor === "depth"
? config.runwareControlPreprocessor
: "canny",
renderMode: renderMode:
config.renderMode === "strict-structure" || config.renderMode === "stylize" config.renderMode === "strict-structure" || config.renderMode === "stylize"
? config.renderMode ? config.renderMode
@ -2031,36 +2023,29 @@ export function AdminSettingsClient() {
// --- 实时画布API配置逻辑 --- // --- 实时画布API配置逻辑 ---
const handleSaveRealtimeCanvasConfig = async () => { const handleSaveRealtimeCanvasConfig = async () => {
try { try {
const runwareApiKey = realtimeCanvasConfig.falApiKey.trim(); const bflApiKey = realtimeCanvasConfig.bflApiKey.trim();
const runwareModel = const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
realtimeCanvasConfig.runwareModel.trim() || DEFAULT_RUNWARE_REALTIME_MODEL; const bflModel = realtimeCanvasConfig.bflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
const runwareStylizeModel =
realtimeCanvasConfig.runwareStylizeModel.trim() || DEFAULT_RUNWARE_STYLIZE_MODEL;
const runwareControlModel =
realtimeCanvasConfig.runwareControlModel.trim() || "runware:25@1";
if (realtimeCanvasConfig.enabled && !runwareApiKey) { if (realtimeCanvasConfig.enabled && !bflApiKey) {
toast.error("启用实时画布功能需要配置 Runware API Key"); toast.error("启用实时画布功能需要配置 BFL API Key");
return; return;
} }
if (realtimeCanvasConfig.enabled) { if (realtimeCanvasConfig.enabled) {
for (const modelId of [runwareModel, runwareStylizeModel].filter(Boolean)) { const probeResponse = await fetch("/api/admin/realtime/model-capability", {
const probeResponse = await fetch("/api/admin/realtime/model-capability", { method: "POST",
method: "POST", headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json" }, body: JSON.stringify({
body: JSON.stringify({ apiKey: bflApiKey,
apiKey: runwareApiKey, modelId: bflModel,
modelId, baseUrl: bflBaseUrl,
}), }),
}); });
const probeData = await probeResponse.json().catch(() => null); const probeData = await probeResponse.json().catch(() => null);
if (!probeResponse.ok || probeData?.supported === false) { if (!probeResponse.ok || probeData?.supported === false) {
toast.error( toast.error(probeData?.message || "模型能力探针失败,已阻止保存");
probeData?.message || "模型能力探针失败:当前模型不支持 seedImage,已阻止保存" return;
);
return;
}
} }
} }
@ -2071,12 +2056,12 @@ export function AdminSettingsClient() {
key: "realtime_canvas_config", key: "realtime_canvas_config",
value: { value: {
enabled: realtimeCanvasConfig.enabled, enabled: realtimeCanvasConfig.enabled,
runwareApiKey, bflApiKey,
falApiKey: runwareApiKey, bflBaseUrl,
runwareModel, bflModel,
runwareStylizeModel, runwareApiKey: bflApiKey,
runwareControlModel, falApiKey: bflApiKey,
runwareControlPreprocessor: realtimeCanvasConfig.runwareControlPreprocessor, runwareModel: bflModel,
renderMode: realtimeCanvasConfig.renderMode, renderMode: realtimeCanvasConfig.renderMode,
throttleInterval: Math.min(260, Math.max(60, Number(realtimeCanvasConfig.throttleInterval) || 100)), throttleInterval: Math.min(260, Math.max(60, Number(realtimeCanvasConfig.throttleInterval) || 100)),
maxBuffering: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.maxBuffering) || 2)), maxBuffering: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.maxBuffering) || 2)),
@ -8359,7 +8344,7 @@ export function AdminSettingsClient() {
实时画布配置 实时画布配置
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
配置实时画布功能的 Runware 参数、模型与 API Key。保存时会自动探针校验模型是否支持 seedImage。 配置实时画布功能的 BFL(FLUX.2)参数、模型与 API Key。保存时会自动探针校验模型可用性。
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 pt-6"> <CardContent className="space-y-6 pt-6">
@ -8384,67 +8369,50 @@ export function AdminSettingsClient() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="realtimeFalApiKey"> <Label htmlFor="realtimeBflApiKey">
Runware API Key <span className="text-destructive">*</span> BFL API Key <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="realtimeFalApiKey" id="realtimeBflApiKey"
type="password" type="password"
value={realtimeCanvasConfig.falApiKey} value={realtimeCanvasConfig.bflApiKey}
onChange={(e) => onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, falApiKey: e.target.value }) setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflApiKey: e.target.value })
} }
placeholder="rw_xxxxxxxxxxxxx" placeholder="bfl_xxxxxxxxxxxxx"
disabled={!realtimeCanvasConfig.enabled} disabled={!realtimeCanvasConfig.enabled}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="runwareModel"> <Label htmlFor="realtimeBflBaseUrl">BFL API Base URL</Label>
Runware 模型 ID <span className="text-destructive">*</span> <Input
id="realtimeBflBaseUrl"
value={realtimeCanvasConfig.bflBaseUrl}
onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflBaseUrl: e.target.value })
}
placeholder={DEFAULT_BFL_BASE_URL}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeBflModel">
FLUX.2 模型 ID <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="runwareModel" id="realtimeBflModel"
value={realtimeCanvasConfig.runwareModel} value={realtimeCanvasConfig.bflModel}
onChange={(e) => onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, runwareModel: e.target.value }) setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflModel: e.target.value })
} }
placeholder={DEFAULT_RUNWARE_REALTIME_MODEL} placeholder={DEFAULT_BFL_REALTIME_MODEL}
disabled={!realtimeCanvasConfig.enabled} disabled={!realtimeCanvasConfig.enabled}
/> />
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-1 gap-4">
<Label htmlFor="runwareStylizeModel">写实增强模型 ID</Label>
<Input
id="runwareStylizeModel"
value={realtimeCanvasConfig.runwareStylizeModel}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
runwareStylizeModel: e.target.value,
})
}
placeholder={DEFAULT_RUNWARE_STYLIZE_MODEL}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="runwareControlModel">ControlNet 模型 ID</Label>
<Input
id="runwareControlModel"
value={realtimeCanvasConfig.runwareControlModel}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
runwareControlModel: e.target.value,
})
}
placeholder="runware:25@1"
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>实时渲染模式</Label> <Label>实时渲染模式</Label>
<Select <Select
@ -8465,30 +8433,9 @@ export function AdminSettingsClient() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<Label>ControlNet 预处理</Label>
<Select
value={realtimeCanvasConfig.runwareControlPreprocessor}
onValueChange={(value: "canny" | "lineart" | "depth") =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
runwareControlPreprocessor: value,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="canny">canny</SelectItem>
<SelectItem value="lineart">lineart</SelectItem>
<SelectItem value="depth">depth</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
推荐:主模型 {DEFAULT_RUNWARE_REALTIME_MODEL} + ControlNet {`runware:25@1`}。写实模型 {DEFAULT_RUNWARE_STYLIZE_MODEL} 作为回退链路。 推荐:实时草图模型使用 `flux-2-klein-4b`,高质量可切换 `flux-2-pro-preview`。
</p> </p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">

View File

@ -1,11 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin"; import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import { probeRunwareSeedImageSupport } from "@/lib/realtime/runware-capability"; import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
const schema = z.object({ const schema = z.object({
apiKey: z.string().min(1), apiKey: z.string().min(1),
modelId: z.string().min(1), modelId: z.string().min(1),
baseUrl: z.string().optional(),
}); });
function mapStatus(code: string) { function mapStatus(code: string) {
@ -19,9 +20,10 @@ export async function POST(request: Request) {
const body = await request.json(); const body = await request.json();
const data = schema.parse(body); const data = schema.parse(body);
const result = await probeRunwareSeedImageSupport({ const result = await probeBflModelSupport({
apiKey: data.apiKey, apiKey: data.apiKey,
modelId: data.modelId, modelId: data.modelId,
baseUrl: data.baseUrl,
}); });
if (!result.supported) { if (!result.supported) {

View File

@ -6,16 +6,15 @@ import {
upsertSystemSetting, upsertSystemSetting,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import { probeRunwareSeedImageSupport } from "@/lib/realtime/runware-capability"; import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
const settingSchema = z.object({ const settingSchema = z.object({
key: z.string(), key: z.string(),
value: z.any(), value: z.any(),
}); });
const DEFAULT_RUNWARE_MODEL = "runware:101@1"; const DEFAULT_BFL_MODEL = "flux-2-klein-4b";
const DEFAULT_RUNWARE_STYLIZE_MODEL = "runware:106@1"; const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
const DEFAULT_RUNWARE_CONTROL_MODEL = "runware:25@1";
function isObjectLike(value: unknown): value is Record<string, unknown> { function isObjectLike(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@ -63,18 +62,11 @@ export async function POST(request: Request) {
} }
const enabled = value.enabled !== false; const enabled = value.enabled !== false;
const runwareApiKey = String(value.runwareApiKey || value.falApiKey || value.apiKey || "").trim(); const bflApiKey = String(
const runwareModel = String(value.runwareModel || DEFAULT_RUNWARE_MODEL).trim() || DEFAULT_RUNWARE_MODEL; value.bflApiKey || value.runwareApiKey || value.falApiKey || value.apiKey || ""
const runwareStylizeModel = ).trim();
String(value.runwareStylizeModel || DEFAULT_RUNWARE_STYLIZE_MODEL).trim() || const bflModel = String(value.bflModel || value.runwareModel || DEFAULT_BFL_MODEL).trim() || DEFAULT_BFL_MODEL;
DEFAULT_RUNWARE_STYLIZE_MODEL; const bflBaseUrl = String(value.bflBaseUrl || DEFAULT_BFL_BASE_URL).trim() || DEFAULT_BFL_BASE_URL;
const runwareControlModel =
String(value.runwareControlModel || DEFAULT_RUNWARE_CONTROL_MODEL).trim() ||
DEFAULT_RUNWARE_CONTROL_MODEL;
const runwareControlPreprocessor =
value.runwareControlPreprocessor === "lineart" || value.runwareControlPreprocessor === "depth"
? value.runwareControlPreprocessor
: "canny";
const renderMode = const renderMode =
value.renderMode === "strict-structure" || value.renderMode === "strict-structure" ||
value.renderMode === "stylize" || value.renderMode === "stylize" ||
@ -83,34 +75,34 @@ export async function POST(request: Request) {
: "balanced"; : "balanced";
if (enabled) { if (enabled) {
if (!runwareApiKey) { if (!bflApiKey) {
return new ChatSDKError("bad_request:api", "启用实时画布必须配置 Runware API Key").toResponse(); return new ChatSDKError("bad_request:api", "启用实时画布必须配置 BFL API Key").toResponse();
} }
for (const modelId of [runwareModel, runwareStylizeModel].filter(Boolean)) { const probeResult = await probeBflModelSupport({
const probeResult = await probeRunwareSeedImageSupport({ apiKey: bflApiKey,
apiKey: runwareApiKey, modelId: bflModel,
modelId, baseUrl: bflBaseUrl,
}); });
if (!probeResult.supported) { if (!probeResult.supported) {
return Response.json( return Response.json(
{ {
code: probeResult.code, code: probeResult.code,
message: probeResult.message, message: probeResult.message,
}, },
{ status: mapProbeStatus(probeResult.code) } { status: mapProbeStatus(probeResult.code) }
); );
}
} }
} }
nextValue = { nextValue = {
...value, ...value,
runwareApiKey, bflApiKey,
falApiKey: runwareApiKey, bflModel,
runwareModel, bflBaseUrl,
runwareStylizeModel, runwareApiKey: bflApiKey,
runwareControlModel, falApiKey: bflApiKey,
runwareControlPreprocessor, apiKey: bflApiKey,
runwareModel: bflModel,
renderMode, renderMode,
}; };
} }

View File

@ -3,10 +3,11 @@ import { getSystemSetting } from "@/lib/db/queries";
type RealtimeCanvasConfig = { type RealtimeCanvasConfig = {
enabled?: boolean; enabled?: boolean;
bflApiKey?: string;
bflModel?: string;
bflBaseUrl?: string;
runwareApiKey?: string; runwareApiKey?: string;
runwareModel?: string; runwareModel?: string;
runwareControlModel?: string;
runwareControlPreprocessor?: "canny" | "lineart" | "depth";
renderMode?: "strict-structure" | "balanced" | "stylize"; renderMode?: "strict-structure" | "balanced" | "stylize";
falApiKey?: string; falApiKey?: string;
apiKey?: string; apiKey?: string;
@ -38,15 +39,15 @@ const defaultConfig = {
jpegQuality: 0.55, jpegQuality: 0.55,
}, },
model: { model: {
id: "runware:101@1", id: "flux-2-klein-4b",
defaults: { defaults: {
steps: 8, steps: 3,
cfgScale: 7, cfgScale: 7,
strength: 0.32, strength: 0.32,
imageSize: "square" as const, imageSize: "square" as const,
}, },
limits: { limits: {
steps: { min: 1, max: 12 }, steps: { min: 1, max: 6 },
cfgScale: { min: 1, max: 14 }, cfgScale: { min: 1, max: 14 },
strength: { min: 0.05, max: 1.0 }, strength: { min: 0.05, max: 1.0 },
}, },
@ -77,20 +78,21 @@ export async function GET() {
); );
} }
const runwareApiKey = String( const bflApiKey = String(
config.runwareApiKey || config.bflApiKey ||
config.runwareApiKey ||
config.falApiKey || config.falApiKey ||
config.apiKey || config.apiKey ||
process.env.RUNWARE_API_KEY || process.env.BFL_API_KEY ||
process.env.RUNWARE_KEY || process.env.BFL_KEY ||
"" ""
).trim(); ).trim();
if (!runwareApiKey) { if (!bflApiKey) {
return Response.json( return Response.json(
{ {
code: "RUNWARE_KEY_MISSING", code: "BFL_KEY_MISSING",
message: "未配置 Runware API Key(请在服务器环境变量或设置中配置)", message: "未配置 BFL API Key(请在服务器环境变量或设置中配置)",
}, },
{ status: 500 } { status: 500 }
); );
@ -138,13 +140,20 @@ export async function GET() {
); );
const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square"; const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square";
const modelId = String( const modelId = String(
config.runwareModel || process.env.RUNWARE_MODEL || defaultConfig.model.id config.bflModel ||
config.runwareModel ||
process.env.BFL_MODEL ||
process.env.RUNWARE_MODEL ||
defaultConfig.model.id
).trim(); ).trim();
return Response.json({ return Response.json({
...defaultConfig, ...defaultConfig,
provider: { provider: {
type: "runware", type: "bfl",
baseUrl:
String(config.bflBaseUrl || process.env.BFL_BASE_URL || "https://api.bfl.ai").trim() ||
"https://api.bfl.ai",
}, },
transport: { transport: {
throttleInterval, throttleInterval,
@ -171,13 +180,5 @@ export async function GET() {
? config.renderMode ? config.renderMode
: "balanced", : "balanced",
}, },
control: {
model: String(config.runwareControlModel || "runware:25@1").trim() || "runware:25@1",
preprocessor:
config.runwareControlPreprocessor === "lineart" ||
config.runwareControlPreprocessor === "depth"
? config.runwareControlPreprocessor
: "canny",
},
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,7 @@ export default function CanvasRealtimePage() {
status: "completed", status: "completed",
title: "实时画布结果", title: "实时画布结果",
prompt, prompt,
model: "fal-ai/flux-2/klein/realtime", model: "bfl/flux-2",
}, },
connections: [], connections: [],
}); });

View File

@ -96,9 +96,9 @@ const defaultRealtimeConfig: RealtimeConfig = {
jpegQuality: 0.55, jpegQuality: 0.55,
}, },
model: { model: {
id: "runware:101@1", id: "flux-2-klein-4b",
defaults: { defaults: {
steps: 8, steps: 3,
cfgScale: 7, cfgScale: 7,
strength: 0.32, strength: 0.32,
imageSize: "square", imageSize: "square",
@ -1414,7 +1414,7 @@ export function RealtimeCanvasDialog({
if (cancelled) return; if (cancelled) return;
connectionRef.current = { connectionRef.current = {
provider: "runware", provider: "bfl",
modelId: resolvedConfig.model.id, modelId: resolvedConfig.model.id,
reconnectToken: refreshToken, reconnectToken: refreshToken,
}; };

View File

@ -0,0 +1,159 @@
const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
const DEFAULT_BFL_MODEL_ID = "flux-2-klein-4b";
const DEFAULT_TIMEOUT_MS = 15_000;
export type BflCapabilityCode =
| "SUPPORTED"
| "AUTH_FAILED"
| "MODEL_INVALID"
| "UPSTREAM_ERROR";
export type BflModelProbeResult = {
supported: boolean;
code: BflCapabilityCode;
message: string;
status?: number;
};
type BflProbeParams = {
apiKey: string;
modelId?: string;
baseUrl?: string;
timeoutMs?: number;
};
function normalizeBaseUrl(raw: string | undefined) {
const next = String(raw || DEFAULT_BFL_BASE_URL).trim();
return (next || DEFAULT_BFL_BASE_URL).replace(/\/+$/, "");
}
function encodeModelPath(modelId: string) {
return modelId
.split("/")
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join("/");
}
function toErrorMessage(payload: any, fallback: string) {
const direct = String(
payload?.error?.message ||
payload?.error ||
payload?.message ||
payload?.detail ||
payload?.msg ||
""
).trim();
return direct || fallback;
}
function looksLikeModelInvalid(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"))
);
}
function hasTaskIdentity(payload: any) {
return Boolean(
(typeof payload?.id === "string" && payload.id.trim()) ||
(typeof payload?.polling_url === "string" && payload.polling_url.trim()) ||
(typeof payload?.response?.id === "string" && payload.response.id.trim()) ||
(typeof payload?.response?.polling_url === "string" &&
payload.response.polling_url.trim())
);
}
export async function probeBflModelSupport(params: BflProbeParams): Promise<BflModelProbeResult> {
const apiKey = String(params.apiKey || "").trim();
const modelId = String(params.modelId || DEFAULT_BFL_MODEL_ID).trim() || DEFAULT_BFL_MODEL_ID;
const baseUrl = normalizeBaseUrl(params.baseUrl);
const timeoutMs = Math.max(3_000, Number(params.timeoutMs || DEFAULT_TIMEOUT_MS));
if (!apiKey) {
return {
supported: false,
code: "AUTH_FAILED",
message: "缺少 BFL API Key。",
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const endpoint = `${baseUrl}/v1/${encodeModelPath(modelId)}`;
const response = await fetch(endpoint, {
method: "POST",
headers: {
"x-key": apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
prompt: "probe image generation capability",
width: 512,
height: 512,
safety_tolerance: 2,
}),
cache: "no-store",
signal: controller.signal,
});
const payload = await response.json().catch(() => null);
const message = toErrorMessage(payload, `BFL 探针请求失败(${response.status})`);
if (response.ok && hasTaskIdentity(payload)) {
return {
supported: true,
code: "SUPPORTED",
message: "模型可用。",
status: response.status,
};
}
if (response.status === 401 || response.status === 403) {
return {
supported: false,
code: "AUTH_FAILED",
message: "BFL API Key 无效或无权限,请检查后重试。",
status: response.status,
};
}
if (response.status === 404 || looksLikeModelInvalid(message)) {
return {
supported: false,
code: "MODEL_INVALID",
message: `模型 ${modelId} 不存在或不可用,请更换模型后重试。`,
status: response.status,
};
}
return {
supported: false,
code: "UPSTREAM_ERROR",
message,
status: response.status,
};
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return {
supported: false,
code: "UPSTREAM_ERROR",
message: "BFL 探针请求超时,请稍后重试。",
};
}
return {
supported: false,
code: "UPSTREAM_ERROR",
message: error instanceof Error ? error.message : "BFL 探针请求失败。",
};
} finally {
clearTimeout(timer);
}
}