Add realtime canvas failover config
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-11 09:49:56 +08:00
parent 9c6abd93da
commit e300153a01
5 changed files with 524 additions and 211 deletions

View File

@ -846,6 +846,10 @@ export function AdminSettingsClient() {
bflApiKey: "",
bflBaseUrl: DEFAULT_BFL_BASE_URL,
bflModel: DEFAULT_BFL_REALTIME_MODEL,
fallbackEnabled: false,
fallbackBflApiKey: "",
fallbackBflBaseUrl: DEFAULT_BFL_BASE_URL,
fallbackBflModel: DEFAULT_BFL_REALTIME_MODEL,
renderMode: "balanced" as "strict-structure" | "balanced" | "stylize",
throttleInterval: 100,
maxBuffering: 2,
@ -1004,6 +1008,13 @@ export function AdminSettingsClient() {
bflApiKey?: string;
bflBaseUrl?: string;
bflModel?: string;
fallbackEnabled?: boolean;
fallbackBflApiKey?: string;
fallbackBflBaseUrl?: string;
fallbackBflModel?: string;
fallbackRunwareApiKey?: string;
fallbackFalApiKey?: string;
fallbackRunwareModel?: string;
runwareApiKey?: string;
falApiKey?: string;
runwareModel?: string;
@ -1024,6 +1035,15 @@ export function AdminSettingsClient() {
bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "",
bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL,
bflModel: config.bflModel || config.runwareModel || DEFAULT_BFL_REALTIME_MODEL,
fallbackEnabled: config.fallbackEnabled === true,
fallbackBflApiKey:
config.fallbackBflApiKey ||
config.fallbackRunwareApiKey ||
config.fallbackFalApiKey ||
"",
fallbackBflBaseUrl: config.fallbackBflBaseUrl || DEFAULT_BFL_BASE_URL,
fallbackBflModel:
config.fallbackBflModel || config.fallbackRunwareModel || DEFAULT_BFL_REALTIME_MODEL,
renderMode:
config.renderMode === "strict-structure" || config.renderMode === "stylize"
? config.renderMode
@ -2060,12 +2080,23 @@ export function AdminSettingsClient() {
const bflApiKey = realtimeCanvasConfig.bflApiKey.trim();
const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
const bflModel = realtimeCanvasConfig.bflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
const fallbackEnabled = realtimeCanvasConfig.fallbackEnabled;
const fallbackBflApiKey = realtimeCanvasConfig.fallbackBflApiKey.trim();
const fallbackBflBaseUrl =
realtimeCanvasConfig.fallbackBflBaseUrl.trim() || DEFAULT_BFL_BASE_URL;
const fallbackBflModel =
realtimeCanvasConfig.fallbackBflModel.trim() || DEFAULT_BFL_REALTIME_MODEL;
if (realtimeCanvasConfig.enabled && !bflApiKey) {
toast.error("启用实时画布功能需要配置 BFL API Key");
return;
}
if (realtimeCanvasConfig.enabled && fallbackEnabled && !fallbackBflApiKey) {
toast.error("启用备用实时服务需要配置备用 BFL API Key");
return;
}
if (realtimeCanvasConfig.enabled) {
const probeResponse = await fetch("/api/admin/realtime/model-capability", {
method: "POST",
@ -2081,6 +2112,23 @@ export function AdminSettingsClient() {
toast.error(probeData?.message || "模型能力探针失败,已阻止保存");
return;
}
if (fallbackEnabled) {
const fallbackProbeResponse = await fetch("/api/admin/realtime/model-capability", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: fallbackBflApiKey,
modelId: fallbackBflModel,
baseUrl: fallbackBflBaseUrl,
}),
});
const fallbackProbeData = await fallbackProbeResponse.json().catch(() => null);
if (!fallbackProbeResponse.ok || fallbackProbeData?.supported === false) {
toast.error(fallbackProbeData?.message || "备用实时服务探针失败,已阻止保存");
return;
}
}
}
const response = await fetch("/api/admin/settings", {
@ -2093,6 +2141,10 @@ export function AdminSettingsClient() {
bflApiKey,
bflBaseUrl,
bflModel,
fallbackEnabled,
fallbackBflApiKey,
fallbackBflBaseUrl,
fallbackBflModel,
runwareApiKey: bflApiKey,
falApiKey: bflApiKey,
runwareModel: bflModel,
@ -8595,6 +8647,73 @@ export function AdminSettingsClient() {
/>
</div>
<div className="rounded-lg border border-dashed p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base">备用实时服务</Label>
<div className="text-sm text-muted-foreground">
主实时服务失败时自动切换到备用 BFL 服务。
</div>
</div>
<Switch
checked={realtimeCanvasConfig.fallbackEnabled}
onCheckedChange={(checked) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, fallbackEnabled: checked })
}
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeFallbackBflApiKey">备用 BFL API Key</Label>
<Input
id="realtimeFallbackBflApiKey"
type="password"
value={realtimeCanvasConfig.fallbackBflApiKey}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
fallbackBflApiKey: e.target.value,
})
}
placeholder="bfl_xxxxxxxxxxxxx"
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeFallbackBflBaseUrl">备用 BFL API Base URL</Label>
<Input
id="realtimeFallbackBflBaseUrl"
value={realtimeCanvasConfig.fallbackBflBaseUrl}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
fallbackBflBaseUrl: e.target.value,
})
}
placeholder={DEFAULT_BFL_BASE_URL}
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeFallbackBflModel">备用 FLUX.2 模型 ID</Label>
<Input
id="realtimeFallbackBflModel"
value={realtimeCanvasConfig.fallbackBflModel}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
fallbackBflModel: e.target.value,
})
}
placeholder={DEFAULT_BFL_REALTIME_MODEL}
disabled={!realtimeCanvasConfig.enabled || !realtimeCanvasConfig.fallbackEnabled}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label>实时渲染模式</Label>

View File

@ -7,15 +7,16 @@ import {
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { probeBflModelSupport } from "@/lib/realtime/bfl-capability";
import {
normalizeRealtimeCanvasConfigForStorage,
resolveRealtimeProviders,
} from "@/lib/realtime/config";
const settingSchema = z.object({
key: z.string(),
value: z.any(),
});
const DEFAULT_BFL_MODEL = "flux-2-klein-4b";
const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
function isObjectLike(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@ -62,49 +63,34 @@ export async function POST(request: Request) {
}
const enabled = value.enabled !== false;
const bflApiKey = String(
value.bflApiKey || value.runwareApiKey || value.falApiKey || value.apiKey || ""
).trim();
const bflModel = String(value.bflModel || value.runwareModel || DEFAULT_BFL_MODEL).trim() || DEFAULT_BFL_MODEL;
const bflBaseUrl = String(value.bflBaseUrl || DEFAULT_BFL_BASE_URL).trim() || DEFAULT_BFL_BASE_URL;
const renderMode =
value.renderMode === "strict-structure" ||
value.renderMode === "stylize" ||
value.renderMode === "balanced"
? value.renderMode
: "balanced";
const normalizedValue = normalizeRealtimeCanvasConfigForStorage(value);
const providers = resolveRealtimeProviders(normalizedValue);
if (enabled) {
if (!bflApiKey) {
return new ChatSDKError("bad_request:api", "启用实时画布必须配置 BFL API Key").toResponse();
if (providers.length === 0) {
return new ChatSDKError("bad_request:api", "启用实时画布必须至少配置一个可用服务").toResponse();
}
const probeResult = await probeBflModelSupport({
apiKey: bflApiKey,
modelId: bflModel,
baseUrl: bflBaseUrl,
});
if (!probeResult.supported) {
return Response.json(
{
code: probeResult.code,
message: probeResult.message,
},
{ status: mapProbeStatus(probeResult.code) }
);
for (const provider of providers) {
const probeResult = await probeBflModelSupport({
apiKey: provider.apiKey,
modelId: provider.model,
baseUrl: provider.baseUrl,
});
if (!probeResult.supported) {
return Response.json(
{
code: probeResult.code,
message: `${provider.label}校验失败:${probeResult.message}`,
},
{ status: mapProbeStatus(probeResult.code) }
);
}
}
}
nextValue = {
...value,
bflApiKey,
bflModel,
bflBaseUrl,
runwareApiKey: bflApiKey,
falApiKey: bflApiKey,
apiKey: bflApiKey,
runwareModel: bflModel,
renderMode,
};
nextValue = normalizedValue;
}
const setting = await upsertSystemSetting({

View File

@ -1,30 +1,10 @@
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
type RealtimeCanvasConfig = {
enabled?: boolean;
bflApiKey?: string;
bflModel?: string;
bflBaseUrl?: string;
runwareApiKey?: string;
runwareModel?: string;
renderMode?: "strict-structure" | "balanced" | "stylize";
falApiKey?: string;
apiKey?: string;
strictSketch?: boolean;
throttleInterval?: number;
maxBuffering?: number;
maxInputResolution?: number;
jpegQuality?: number;
defaults?: {
steps?: number;
cfgScale?: number;
strength?: number;
scheduleMu?: number;
feedback?: number;
imageSize?: "square" | "square_hd";
};
};
import {
DEFAULT_BFL_BASE_URL,
RealtimeCanvasConfig,
resolveRealtimeProviders,
} from "@/lib/realtime/config";
const defaultConfig = {
session: {
@ -78,21 +58,14 @@ export async function GET() {
);
}
const bflApiKey = String(
config.bflApiKey ||
config.runwareApiKey ||
config.falApiKey ||
config.apiKey ||
process.env.BFL_API_KEY ||
process.env.BFL_KEY ||
""
).trim();
const providers = resolveRealtimeProviders(config);
const activeProvider = providers[0];
if (!bflApiKey) {
if (!activeProvider) {
return Response.json(
{
code: "BFL_KEY_MISSING",
message: "未配置 BFL API Key(请在服务器环境变量或设置中配置)",
message: "未配置可用的实时画布服务(请在服务器环境变量或设置中配置)",
},
{ status: 500 }
);
@ -139,21 +112,24 @@ export async function GET() {
defaultConfig.model.limits.strength.max
);
const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square";
const modelId = String(
config.bflModel ||
config.runwareModel ||
process.env.BFL_MODEL ||
process.env.RUNWARE_MODEL ||
defaultConfig.model.id
).trim();
const modelId = activeProvider.model;
return Response.json({
...defaultConfig,
provider: {
type: "bfl",
baseUrl:
String(config.bflBaseUrl || process.env.BFL_BASE_URL || "https://api.bfl.ai").trim() ||
"https://api.bfl.ai",
baseUrl: activeProvider.baseUrl || DEFAULT_BFL_BASE_URL,
},
failover: {
enabled: providers.length > 1,
fallbackProvider:
providers.length > 1
? {
type: "bfl",
baseUrl: providers[1].baseUrl,
model: providers[1].model,
}
: null,
},
transport: {
throttleInterval,

View File

@ -1,18 +1,11 @@
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
type RealtimeCanvasConfig = {
enabled?: boolean;
bflApiKey?: string;
bflModel?: string;
bflBaseUrl?: string;
runwareApiKey?: string;
runwareModel?: string;
falApiKey?: string;
apiKey?: string;
negativePrompt?: string;
maxInputResolution?: number;
};
import {
DEFAULT_BFL_MODEL,
RealtimeCanvasConfig,
RealtimeProviderConfig,
resolveRealtimeProviders,
} from "@/lib/realtime/config";
type GeneratePayload = {
requestId?: string;
@ -48,8 +41,6 @@ type BflSubmitResponse = {
const FALLBACK_REALTIME_PROMPT =
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
const DEFAULT_BFL_MODEL = "flux-2-klein-4b";
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
@ -72,11 +63,6 @@ function resolveSeed(seed: unknown) {
return normalized;
}
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("/")
@ -168,6 +154,22 @@ type SubmitTaskResult = {
status: number;
};
type ProviderAttemptResult =
| {
ok: true;
imageUrl: string;
taskId: string;
imageField: "input_image" | "image_prompt";
fallbackWarning: string;
provider: RealtimeProviderConfig;
}
| {
ok: false;
message: string;
status: number;
provider: RealtimeProviderConfig;
};
async function submitBflTask(params: {
apiKey: string;
baseUrl: string;
@ -290,6 +292,97 @@ async function pollBflResult(params: {
};
}
async function generateWithProvider(params: {
provider: RealtimeProviderConfig;
imagePrompt: string;
prompt: string;
size: number;
seed?: number;
}): Promise<ProviderAttemptResult> {
const imageFieldCandidates = getBflImageFieldCandidates();
let selectedImageField: "input_image" | "image_prompt" = imageFieldCandidates[0];
let submitResult: SubmitTaskResult | null = null;
let fallbackWarning = "";
for (let index = 0; index < imageFieldCandidates.length; index += 1) {
selectedImageField = imageFieldCandidates[index];
const requestBody: Record<string, unknown> = {
prompt: params.prompt,
width: params.size,
height: params.size,
output_format: "jpeg",
safety_tolerance: 2,
[selectedImageField]: params.imagePrompt,
...(typeof params.seed === "number" ? { seed: params.seed } : {}),
};
submitResult = await submitBflTask({
apiKey: params.provider.apiKey,
baseUrl: params.provider.baseUrl,
model: params.provider.model,
body: requestBody,
});
if (!submitResult.errorMessage) {
break;
}
if (
index < imageFieldCandidates.length - 1 &&
shouldRetryWithAlternateImageField(submitResult.errorMessage)
) {
fallbackWarning = "模型参数兼容性已自动切换,继续生成。";
continue;
}
return {
ok: false,
message: submitResult.errorMessage || "实时生成提交失败",
status: submitResult.status || 502,
provider: params.provider,
};
}
if (!submitResult) {
return {
ok: false,
message: "实时生成提交失败",
status: 502,
provider: params.provider,
};
}
let finalImageUrl = submitResult.imageUrl;
if (!finalImageUrl) {
const pollResult = await pollBflResult({
apiKey: params.provider.apiKey,
baseUrl: params.provider.baseUrl,
pollingUrl: submitResult.pollingUrl,
taskId: submitResult.taskId,
timeoutMs: 110_000,
});
if (!pollResult.imageUrl) {
return {
ok: false,
message: pollResult.errorMessage || "实时生成未返回图像",
status: pollResult.status || 502,
provider: params.provider,
};
}
finalImageUrl = pollResult.imageUrl;
}
return {
ok: true,
imageUrl: finalImageUrl,
taskId: submitResult.taskId,
imageField: selectedImageField,
fallbackWarning,
provider: params.provider,
};
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
@ -318,33 +411,17 @@ export async function POST(request: Request) {
);
}
const bflApiKey = String(
config.bflApiKey ||
config.runwareApiKey ||
config.falApiKey ||
config.apiKey ||
process.env.BFL_API_KEY ||
process.env.BFL_KEY ||
""
).trim();
if (!bflApiKey) {
const providers = resolveRealtimeProviders(config);
if (providers.length === 0) {
return Response.json(
{
code: "BFL_KEY_MISSING",
message: "未配置 BFL API Key",
message: "未配置可用的实时画布服务",
},
{ status: 500 }
);
}
const model = String(
config.bflModel ||
config.runwareModel ||
process.env.BFL_MODEL ||
process.env.RUNWARE_MODEL ||
DEFAULT_BFL_MODEL
).trim() || DEFAULT_BFL_MODEL;
const baseUrl = normalizeBaseUrl(config.bflBaseUrl || process.env.BFL_BASE_URL);
const maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536);
const size = resolveSquareSize(body.imageSize, maxInputResolution);
const seed = resolveSeed(body.seed);
@ -373,100 +450,69 @@ export async function POST(request: Request) {
);
}
const imageFieldCandidates = getBflImageFieldCandidates();
const providerErrors: string[] = [];
let selectedImageField: "input_image" | "image_prompt" = imageFieldCandidates[0];
let submitResult: SubmitTaskResult | null = null;
let fallbackWarning = "";
for (let index = 0; index < imageFieldCandidates.length; index += 1) {
selectedImageField = imageFieldCandidates[index];
const requestBody: Record<string, unknown> = {
for (const provider of providers) {
const result = await generateWithProvider({
provider,
imagePrompt,
prompt: positivePrompt,
width: size,
height: size,
output_format: "jpeg",
safety_tolerance: 2,
[selectedImageField]: imagePrompt,
...(typeof seed === "number" ? { seed } : {}),
};
submitResult = await submitBflTask({
apiKey: bflApiKey,
baseUrl,
model,
body: requestBody,
size,
seed,
});
if (!submitResult.errorMessage) {
break;
}
if (index < imageFieldCandidates.length - 1 && shouldRetryWithAlternateImageField(submitResult.errorMessage)) {
fallbackWarning = "模型参数兼容性已自动切换,继续生成。";
if (!result.ok) {
providerErrors.push(`${provider.label}(${provider.baseUrl}): ${result.message}`);
continue;
}
break;
}
if (!submitResult || submitResult.errorMessage) {
return Response.json(
{
code: "BFL_REQUEST_FAILED",
message: submitResult?.errorMessage || "BFL 请求失败",
},
{ status: 502 }
);
}
let finalImageUrl = submitResult.imageUrl;
if (!finalImageUrl) {
const pollResult = await pollBflResult({
apiKey: bflApiKey,
baseUrl,
pollingUrl: submitResult.pollingUrl,
taskId: submitResult.taskId,
timeoutMs: 110_000,
});
if (!pollResult.imageUrl) {
return Response.json(
{
code: "BFL_EMPTY_OUTPUT",
message: pollResult.errorMessage || "BFL 未返回图像",
},
{ status: 502 }
);
const warningParts = [result.fallbackWarning];
if (result.provider.source === "fallback") {
warningParts.push("主实时服务异常,已自动切换到备用服务。");
}
finalImageUrl = pollResult.imageUrl;
const warning = warningParts.filter(Boolean).join(" ");
console.log("[RealtimeBFL] frame success", {
userId: session.user.id,
requestId: body.requestId || null,
model: result.provider.model || DEFAULT_BFL_MODEL,
providerSource: result.provider.source,
providerBaseUrl: result.provider.baseUrl,
taskId: result.taskId || null,
hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT),
imageField: result.imageField,
qualityProfile: body.qualityProfile || "refine",
size,
roi: body.roi || null,
hasMask: Boolean(body.maskImageUrl),
warning: warning || null,
});
return Response.json({
ok: true,
provider: "bfl",
providerSource: result.provider.source,
model: result.provider.model,
transport: "http-poll",
taskUUID: result.taskId || undefined,
imageDataURI: result.imageUrl.startsWith("data:")
? result.imageUrl
: !/^https?:\/\//i.test(result.imageUrl)
? `data:image/jpeg;base64,${result.imageUrl}`
: undefined,
imageUrl: /^https?:\/\//i.test(result.imageUrl) ? result.imageUrl : undefined,
warning: warning || undefined,
});
}
console.log("[RealtimeBFL] frame success", {
userId: session.user.id,
requestId: body.requestId || null,
model,
taskId: submitResult.taskId || null,
hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT),
imageField: selectedImageField,
qualityProfile: body.qualityProfile || "refine",
size,
roi: body.roi || null,
hasMask: Boolean(body.maskImageUrl),
warning: fallbackWarning || null,
});
return Response.json({
ok: true,
provider: "bfl",
model,
transport: "http-poll",
taskUUID: submitResult.taskId || undefined,
imageDataURI: finalImageUrl.startsWith("data:")
? finalImageUrl
: !/^https?:\/\//i.test(finalImageUrl)
? `data:image/jpeg;base64,${finalImageUrl}`
: undefined,
imageUrl: /^https?:\/\//i.test(finalImageUrl) ? finalImageUrl : undefined,
warning: fallbackWarning || undefined,
});
return Response.json(
{
code: "BFL_REQUEST_FAILED",
message:
providerErrors.length > 0
? `主备用实时服务均不可用:${providerErrors.join(";")}`
: "实时生成失败",
},
{ status: 502 }
);
}

186
lib/realtime/config.ts Normal file
View File

@ -0,0 +1,186 @@
export const DEFAULT_BFL_MODEL = "flux-2-klein-4b";
export const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
export type RealtimeCanvasConfig = {
enabled?: boolean;
bflApiKey?: string;
bflModel?: string;
bflBaseUrl?: string;
runwareApiKey?: string;
runwareModel?: string;
falApiKey?: string;
apiKey?: string;
fallbackEnabled?: boolean;
fallbackBflApiKey?: string;
fallbackBflModel?: string;
fallbackBflBaseUrl?: string;
fallbackRunwareApiKey?: string;
fallbackRunwareModel?: string;
fallbackFalApiKey?: string;
fallbackApiKey?: string;
negativePrompt?: string;
renderMode?: "strict-structure" | "balanced" | "stylize";
strictSketch?: boolean;
throttleInterval?: number;
maxBuffering?: number;
maxInputResolution?: number;
jpegQuality?: number;
defaults?: {
steps?: number;
cfgScale?: number;
strength?: number;
scheduleMu?: number;
feedback?: number;
imageSize?: "square" | "square_hd";
};
};
export type RealtimeProviderConfig = {
apiKey: string;
baseUrl: string;
model: string;
source: "primary" | "fallback";
label: string;
};
function trimString(value: unknown) {
return String(value || "").trim();
}
export function normalizeBaseUrl(raw: string | undefined) {
const next = trimString(raw || DEFAULT_BFL_BASE_URL);
return (next || DEFAULT_BFL_BASE_URL).replace(/\/+$/, "");
}
function hasFallbackIntent(config: RealtimeCanvasConfig) {
if (config.fallbackEnabled === true) return true;
if (config.fallbackEnabled === false) return false;
return Boolean(
trimString(
config.fallbackBflApiKey ||
config.fallbackRunwareApiKey ||
config.fallbackFalApiKey ||
config.fallbackApiKey ||
config.fallbackBflBaseUrl ||
config.fallbackBflModel ||
config.fallbackRunwareModel
)
);
}
export function resolvePrimaryRealtimeProvider(
config: RealtimeCanvasConfig,
env: NodeJS.ProcessEnv = process.env
): RealtimeProviderConfig | null {
const apiKey = trimString(
config.bflApiKey ||
config.runwareApiKey ||
config.falApiKey ||
config.apiKey ||
env.BFL_API_KEY ||
env.BFL_KEY
);
if (!apiKey) return null;
const model =
trimString(config.bflModel || config.runwareModel || env.BFL_MODEL || env.RUNWARE_MODEL) ||
DEFAULT_BFL_MODEL;
const baseUrl = normalizeBaseUrl(config.bflBaseUrl || env.BFL_BASE_URL);
return {
apiKey,
model,
baseUrl,
source: "primary",
label: "主实时服务",
};
}
export function resolveFallbackRealtimeProvider(
config: RealtimeCanvasConfig
): RealtimeProviderConfig | null {
if (!hasFallbackIntent(config)) return null;
const apiKey = trimString(
config.fallbackBflApiKey ||
config.fallbackRunwareApiKey ||
config.fallbackFalApiKey ||
config.fallbackApiKey
);
if (!apiKey) return null;
const model =
trimString(config.fallbackBflModel || config.fallbackRunwareModel) || DEFAULT_BFL_MODEL;
const baseUrl = normalizeBaseUrl(config.fallbackBflBaseUrl || DEFAULT_BFL_BASE_URL);
return {
apiKey,
model,
baseUrl,
source: "fallback",
label: "备用实时服务",
};
}
export function resolveRealtimeProviders(
config: RealtimeCanvasConfig,
env: NodeJS.ProcessEnv = process.env
) {
const providers: RealtimeProviderConfig[] = [];
const primary = resolvePrimaryRealtimeProvider(config, env);
const fallback = resolveFallbackRealtimeProvider(config);
if (primary) {
providers.push(primary);
}
if (
fallback &&
!providers.some(
(provider) =>
provider.apiKey === fallback.apiKey &&
provider.baseUrl === fallback.baseUrl &&
provider.model === fallback.model
)
) {
providers.push(fallback);
}
return providers;
}
export function normalizeRealtimeCanvasConfigForStorage(
value: Record<string, unknown>,
env: NodeJS.ProcessEnv = process.env
): RealtimeCanvasConfig {
const renderMode =
value.renderMode === "strict-structure" ||
value.renderMode === "stylize" ||
value.renderMode === "balanced"
? value.renderMode
: "balanced";
const primary = resolvePrimaryRealtimeProvider(value as RealtimeCanvasConfig, env);
const fallback = resolveFallbackRealtimeProvider(value as RealtimeCanvasConfig);
return {
...value,
bflApiKey: primary?.apiKey || "",
bflModel: primary?.model || DEFAULT_BFL_MODEL,
bflBaseUrl: primary?.baseUrl || normalizeBaseUrl(env.BFL_BASE_URL),
runwareApiKey: primary?.apiKey || "",
falApiKey: primary?.apiKey || "",
apiKey: primary?.apiKey || "",
runwareModel: primary?.model || DEFAULT_BFL_MODEL,
fallbackEnabled: Boolean(fallback),
fallbackBflApiKey: fallback?.apiKey || "",
fallbackBflModel: fallback?.model || DEFAULT_BFL_MODEL,
fallbackBflBaseUrl: fallback?.baseUrl || DEFAULT_BFL_BASE_URL,
fallbackRunwareApiKey: fallback?.apiKey || "",
fallbackFalApiKey: fallback?.apiKey || "",
fallbackApiKey: fallback?.apiKey || "",
fallbackRunwareModel: fallback?.model || DEFAULT_BFL_MODEL,
renderMode,
};
}