Add BFL image runtime provider
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-21 01:36:53 +08:00
parent dbfc8e97d2
commit ccc2e45b12
5 changed files with 533 additions and 22 deletions

View File

@ -457,6 +457,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
}
if (
normalized === "alibaba-image" ||
normalized === "bfl-image" ||
normalized === "ruxa-image" ||
normalized === "fal-image" ||
normalized === "openai-image" ||
@ -1375,6 +1376,7 @@ function pickAgentSourceImageUrls(params: {
const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"openai-image",
@ -2015,6 +2017,7 @@ function ensureImageAnalysisSummarySection(message: string, summary: string): st
*/
async function convertMessagesWithImages(messages: ChatMessage[], providerType: string) {
if (
providerType === "bfl-image" ||
providerType === "ruxa-image" ||
providerType === "fal-image" ||
providerType === "openai-image" ||
@ -2177,6 +2180,8 @@ function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-");
} else if (thumbnailUrl.includes("volcano-thumb-")) {
originalUrl = thumbnailUrl.replace("volcano-thumb-", "volcano-orig-");
} else if (thumbnailUrl.includes("bfl-image-thumb-")) {
originalUrl = thumbnailUrl.replace("bfl-image-thumb-", "bfl-image-orig-");
} else if (thumbnailUrl.includes("alibaba-image-thumb-")) {
originalUrl = thumbnailUrl.replace("alibaba-image-thumb-", "alibaba-image-orig-");
} else if (thumbnailUrl.includes("ruxa-image-thumb-")) {
@ -7976,6 +7981,7 @@ export async function POST(request: Request) {
isImageGenerationModel &&
[
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"openai-image",
@ -8061,7 +8067,9 @@ export async function POST(request: Request) {
const tryGenerateImage = async (modelIdToUse: string, isRetry: boolean) => {
// 转换消息(图片编辑模型统一使用 URL 输入)
const providerForConvert =
(languageModel as any).provider === "ruxa-image"
(languageModel as any).provider === "bfl-image"
? "bfl-image"
: (languageModel as any).provider === "ruxa-image"
? "ruxa-image"
: (languageModel as any).provider === "fal-image"
? "fal-image"
@ -8140,10 +8148,11 @@ export async function POST(request: Request) {
const providerPriority: Record<string, number> = {
"volcano-vision": 0,
"alibaba-image": 1,
"ruxa-image": 2,
"fal-image": 3,
"openai-image": 4,
runway: 5,
"bfl-image": 2,
"ruxa-image": 3,
"fal-image": 4,
"openai-image": 5,
runway: 6,
gemini: 6,
"huanke-gemini": 7,
};
@ -8166,7 +8175,9 @@ export async function POST(request: Request) {
}
const convertProvider =
candidate.provider.type === "ruxa-image"
candidate.provider.type === "bfl-image"
? "bfl-image"
: candidate.provider.type === "ruxa-image"
? "ruxa-image"
: candidate.provider.type === "fal-image"
? "fal-image"
@ -8531,10 +8542,11 @@ export async function POST(request: Request) {
if (!fallbackSuccess) {
const providerPriority: Record<string, number> = {
"alibaba-image": 0,
"ruxa-image": 1,
"fal-image": 2,
"openai-image": 3,
runway: 4,
"bfl-image": 1,
"ruxa-image": 2,
"fal-image": 3,
"openai-image": 4,
runway: 5,
gemini: 5,
"huanke-gemini": 6,
"volcano-vision": 7,
@ -8558,7 +8570,9 @@ export async function POST(request: Request) {
}
const convertProvider =
candidate.provider.type === "ruxa-image"
candidate.provider.type === "bfl-image"
? "bfl-image"
: candidate.provider.type === "ruxa-image"
? "ruxa-image"
: candidate.provider.type === "fal-image"
? "fal-image"
@ -9352,10 +9366,11 @@ export async function POST(request: Request) {
const providerPriority: Record<string, number> = {
"volcano-vision": 0,
"alibaba-image": 1,
"ruxa-image": 2,
"fal-image": 3,
"openai-image": 4,
runway: 5,
"bfl-image": 2,
"ruxa-image": 3,
"fal-image": 4,
"openai-image": 5,
runway: 6,
gemini: 6,
"huanke-gemini": 7,
};
@ -9383,7 +9398,9 @@ export async function POST(request: Request) {
}
const convertProvider =
candidate.provider.type === "ruxa-image"
candidate.provider.type === "bfl-image"
? "bfl-image"
: candidate.provider.type === "ruxa-image"
? "ruxa-image"
: candidate.provider.type === "fal-image"
? "fal-image"

View File

@ -18,6 +18,7 @@ import { createHuankeGemini } from "./providers/huanke-gemini";
import { createQianwenVideo } from "./providers/qianwen-video";
import { createVolcanoVision } from "./providers/volcano-vision";
import { createAlibabaImage } from "./providers/alibaba-image";
import { createBflImage } from "./providers/bfl-image";
import { createRuxaImage } from "./providers/ruxa-image";
import { createFalImage } from "./providers/fal-image";
import { createOpenAIImage } from "./providers/openai-image";
@ -190,6 +191,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
return alibabaImageProvider as any;
}
case "bfl-image": {
const bflImageProvider = createBflImage({
baseURL: resolvedBaseUrl,
apiKey: provider.apiKey,
});
console.log(`[Provider] Created BFL Image generation provider: ${resolvedBaseUrl}`);
return bflImageProvider as any;
}
case "ruxa-image": {
// RUXA 图片编辑服务
const ruxaImageProvider = createRuxaImage({
@ -344,6 +354,10 @@ export async function getDynamicModel(
console.log("[getDynamicModel] Getting Alibaba Image chat model..."); // 调试日志
baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Alibaba Image model retrieved:", baseModel ? "success" : "failed"); // 调试日志
} else if (provider.type === "bfl-image") {
console.log("[getDynamicModel] Getting BFL Image chat model...");
baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] BFL Image model retrieved:", baseModel ? "success" : "failed");
} else if (provider.type === "ruxa-image") {
// RUXA 图片编辑生成使用 chat() 方法
console.log("[getDynamicModel] Getting Ruxa Image chat model..."); // 调试日志

View File

@ -10,6 +10,7 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
"gemini",
"huanke-gemini",
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"openai-image",

View File

@ -48,6 +48,7 @@ const CHAT_PROVIDER_TYPES = [
const IMAGE_ONLY_PROVIDER_TYPES = new Set([
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"openai-image",
@ -154,7 +155,7 @@ const INTENT_MODEL_MAP: Record<
> = {
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
image_edit: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini"],
requireVision: true,
requireImageGen: true,
description: "图片编辑",
@ -169,7 +170,7 @@ const INTENT_MODEL_MAP: Record<
// 图片生成:优先灵魂4.5
image_gen: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini", "volcano-vision"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "huanke-gemini", "volcano-vision"],
requireImageGen: true,
description: "图片生成",
},
@ -197,25 +198,25 @@ const SUB_INTENT_OVERRIDES: Record<
> = {
// 抠图:alibaba-image 效果最好
remove_background: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
reason: "抠图首选灵魂4.5",
},
// 换背景:alibaba-image 支持场景生成
replace_background: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini"],
reason: "换背景首选灵魂4.5",
},
// 图片增强
enhance: {
providerTypes: ["alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "volcano-vision"],
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "gemini", "volcano-vision"],
reason: "增强首选灵魂4.5",
},
// 风格迁移:gemini 可能更有创意
style_transfer: {
providerTypes: ["gemini", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "runway"],
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway"],
reason: "风格迁移优选Gemini",
},
};

View File

@ -0,0 +1,478 @@
import "server-only";
import sharp from "sharp";
import { uploadFile } from "@/lib/storage/service";
interface BflImageConfig {
apiKey: string;
baseURL: string;
}
type UploadedImage = {
thumbnail: string;
original: string;
};
type BflImageParams = {
aspectRatio?: string;
imageSize?: string;
seed?: number;
};
type BflSubmitResponse = {
id?: string;
status?: string;
task_status?: string;
polling_url?: string;
response?: {
id?: string;
status?: string;
task_status?: string;
polling_url?: string;
result?: unknown;
};
result?: unknown;
sample?: string;
output?: unknown;
images?: unknown;
image_url?: string;
error?: unknown;
message?: string;
detail?: string;
};
const DEFAULT_TIMEOUT_MS = 180000;
const SUPPORTED_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
] as const;
function normalizeBaseURL(baseURL: string) {
const normalized = String(baseURL || "").trim().replace(/\/+$/, "");
if (!normalized) return "https://api.bfl.ai";
return /\/v1$/i.test(normalized) ? normalized.slice(0, -3) : normalized;
}
function encodeModelPath(modelId: string) {
return modelId
.split("/")
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join("/");
}
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = String(imageSize || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
return "2K";
}
function normalizeAspectRatio(value?: string) {
const normalized = String(value || "").trim();
if (!normalized || normalized.toLowerCase() === "auto") return "auto";
if ((SUPPORTED_ASPECT_RATIOS as readonly string[]).includes(normalized)) return normalized;
return undefined;
}
function parseAspectRatioValue(ratio: string) {
const [width, height] = ratio.split(":").map((item) => Number(item));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return 1;
}
return width / height;
}
function roundToMultipleOf32(value: number) {
return Math.max(32, Math.round(value / 32) * 32);
}
function resolveBflDimensions(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
const baseMap: Record<typeof imageSize, number> = {
"1K": 1024,
"2K": 1536,
"4K": 2048,
};
const base = baseMap[imageSize] || 1536;
const ratioValue = parseAspectRatioValue(aspectRatio);
if (ratioValue >= 1) {
return {
width: base,
height: roundToMultipleOf32(base / ratioValue),
};
}
return {
width: roundToMultipleOf32(base * ratioValue),
height: base,
};
}
async function inferAspectRatioFromImageSource(imageUrl: string): Promise<string | null> {
const normalized = String(imageUrl || "").trim();
if (!normalized) return null;
try {
let buffer: Buffer | null = null;
if (normalized.startsWith("data:")) {
const match = normalized.match(/^data:([A-Za-z0-9.+/-]+);base64,(.+)$/);
if (!match) return null;
buffer = Buffer.from(match[2], "base64");
} else if (/^https?:\/\//i.test(normalized)) {
const response = await fetch(normalized, { cache: "no-store" });
if (!response.ok) return null;
buffer = Buffer.from(await response.arrayBuffer());
}
if (!buffer?.length) return null;
const metadata = await sharp(buffer).metadata();
if (!metadata.width || !metadata.height || metadata.height <= 0) return null;
const actualRatio = metadata.width / metadata.height;
let closest = "1:1";
let minDiff = Number.POSITIVE_INFINITY;
for (const candidate of SUPPORTED_ASPECT_RATIOS) {
const diff = Math.abs(parseAspectRatioValue(candidate) - actualRatio);
if (diff < minDiff) {
minDiff = diff;
closest = candidate;
}
}
return closest;
} catch (error) {
console.warn("[BFL Image] Failed to infer aspect ratio:", error);
return null;
}
}
function detectImageExtension(imageUrl: string, contentType?: string | null) {
const type = String(contentType || "").toLowerCase();
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
if (type.includes("webp")) return "webp";
if (type.includes("png")) return "png";
const cleanUrl = imageUrl.split("?")[0]?.toLowerCase() || "";
if (cleanUrl.endsWith(".jpg") || cleanUrl.endsWith(".jpeg")) return "jpg";
if (cleanUrl.endsWith(".webp")) return "webp";
if (cleanUrl.endsWith(".png")) return "png";
return "png";
}
function generateFilename(prefix: string, extension: string) {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8);
return `${prefix}-${timestamp}-${random}.${extension}`;
}
async function createThumbnail(buffer: Buffer) {
try {
return await sharp(buffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
} catch (error) {
console.error("[BFL Image] Failed to create thumbnail:", error);
return buffer;
}
}
async function uploadGeneratedBuffer(
buffer: Buffer,
extension: string,
): Promise<UploadedImage> {
const thumbnailBuffer = await createThumbnail(buffer);
const originalFilename = generateFilename("bfl-image-orig", extension);
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const originalUploaded = await uploadFile(originalFilename, originalArrayBuffer);
const thumbFilename = generateFilename("bfl-image-thumb", "jpg");
const thumbArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const thumbUploaded = await uploadFile(thumbFilename, thumbArrayBuffer);
return {
thumbnail: thumbUploaded.url,
original: originalUploaded.url,
};
}
async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage> {
const response = await fetch(imageUrl, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to download generated image: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const extension = detectImageExtension(imageUrl, response.headers.get("content-type"));
return uploadGeneratedBuffer(buffer, extension);
}
function extractPromptAndImages(messages: Array<{ role: string; content: any }>) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) {
throw new Error("No user message found");
}
let prompt = "";
const imageUrls: string[] = [];
const pushImageUrl = (value?: string) => {
if (!value) return;
const normalized = String(value).trim();
if (!normalized) return;
if (!imageUrls.includes(normalized)) {
imageUrls.push(normalized);
}
};
if (typeof lastUserMessage.content === "string") {
prompt = lastUserMessage.content.trim();
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
if (part?.type === "text" && typeof part.text === "string") {
prompt = `${prompt} ${part.text}`.trim();
} else if (part?.type === "image_url" && part.image_url?.url) {
pushImageUrl(part.image_url.url);
} else if (part?.type === "file") {
pushImageUrl(part.url || part.data);
} else if (part?.type === "image") {
pushImageUrl(part.image || part.url);
}
}
}
if (!prompt) {
throw new Error("No prompt found in user message");
}
return { prompt, imageUrls };
}
function extractBflImageUrl(payload: any): string {
const candidates = [
payload?.sample,
payload?.result?.sample,
payload?.response?.result?.sample,
payload?.output?.[0],
payload?.result?.output?.[0],
payload?.images?.[0],
payload?.result?.images?.[0],
payload?.image_url,
payload?.result?.image_url,
payload?.url,
payload?.result?.url,
];
for (const candidate of candidates) {
const value = String(candidate || "").trim();
if (!value) continue;
if (value.startsWith("data:")) return value;
if (/^https?:\/\//i.test(value)) return value;
}
return "";
}
function extractErrorMessage(payload: any, fallback: string) {
const message = String(
payload?.error?.message ||
payload?.error ||
payload?.detail ||
payload?.message ||
payload?.response?.error?.message ||
payload?.response?.error ||
""
).trim();
return message || fallback;
}
async function submitBflImageRequest(params: {
baseURL: string;
apiKey: string;
model: string;
prompt: string;
imageUrl: string;
width: number;
height: number;
seed?: number;
}) {
const endpoint = `${normalizeBaseURL(params.baseURL)}/v1/${encodeModelPath(params.model)}`;
const response = await fetch(endpoint, {
method: "POST",
headers: {
"x-key": params.apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
prompt: params.prompt,
input_image: params.imageUrl,
width: params.width,
height: params.height,
output_format: "jpeg",
safety_tolerance: 2,
...(typeof params.seed === "number" ? { seed: params.seed } : {}),
}),
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as BflSubmitResponse | null;
const imageUrl = extractBflImageUrl(payload);
const taskId = String(payload?.id || payload?.response?.id || "").trim();
const pollingUrl = String(payload?.polling_url || payload?.response?.polling_url || "").trim();
if (response.ok && (imageUrl || taskId || pollingUrl)) {
return {
imageUrl,
taskId,
pollingUrl,
errorMessage: "",
};
}
return {
imageUrl,
taskId,
pollingUrl,
errorMessage: extractErrorMessage(payload, `BFL 请求失败(${response.status})`),
};
}
async function pollBflResult(params: {
baseURL: string;
apiKey: string;
pollingUrl?: string;
taskId?: string;
}) {
const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
while (Date.now() < deadline) {
const pollingUrl =
String(params.pollingUrl || "").trim() ||
`${normalizeBaseURL(params.baseURL)}/v1/get_result?id=${encodeURIComponent(String(params.taskId || ""))}`;
const response = await fetch(pollingUrl, {
method: "GET",
headers: {
"x-key": params.apiKey,
Accept: "application/json",
},
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as BflSubmitResponse | null;
const imageUrl = extractBflImageUrl(payload);
if (imageUrl) return imageUrl;
const status = String(
payload?.status ||
payload?.task_status ||
payload?.response?.status ||
payload?.response?.task_status ||
""
)
.trim()
.toLowerCase();
if (status === "failed" || status === "error" || status === "cancelled") {
throw new Error(extractErrorMessage(payload, "BFL 任务执行失败"));
}
await new Promise((resolve) => setTimeout(resolve, 1200));
}
throw new Error("BFL 轮询超时");
}
function formatMarkdown(image: UploadedImage, imageSize: string) {
return `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`;
}
export function createBflImage({ apiKey, baseURL }: BflImageConfig) {
return {
provider: "bfl-image",
chat: (modelId: string) => ({
provider: "bfl-image",
doGenerate: async (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: BflImageParams;
overrideModelId?: string;
}) => {
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
if (imageUrls.length === 0) {
throw new Error("BFL 图片编辑模型需要至少上传一张图片。");
}
const activeModelId = options.overrideModelId || modelId;
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
const normalizedAspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
const aspectRatio =
normalizedAspectRatio === "auto"
? await inferAspectRatioFromImageSource(imageUrls[0] || "")
: normalizedAspectRatio;
const { width, height } = resolveBflDimensions(aspectRatio || "1:1", imageSize);
const submitResult = await submitBflImageRequest({
baseURL,
apiKey,
model: activeModelId,
prompt,
imageUrl: imageUrls[0],
width,
height,
seed: options.imageParams?.seed,
});
if (submitResult.errorMessage) {
throw new Error(submitResult.errorMessage);
}
const finalImageUrl =
submitResult.imageUrl ||
(await pollBflResult({
baseURL,
apiKey,
pollingUrl: submitResult.pollingUrl,
taskId: submitResult.taskId,
}));
if (!finalImageUrl) {
throw new Error("BFL 未返回可用图片");
}
const uploaded = await downloadAndUploadImage(finalImageUrl);
const text = formatMarkdown(uploaded, imageSize);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
},
doStream: async function* (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: BflImageParams;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
}),
};
}