fix: route fal nano banana edit requests
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-04-27 20:54:44 +08:00
parent 3f1e4b24c6
commit 7b69e7dcee
5 changed files with 253 additions and 16 deletions

View File

@ -9,10 +9,13 @@ import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
const imageEditCount = FAL_IMAGE_MODEL_PRESETS.filter(
(preset) => preset.capabilities?.kind === "image-edit"
).length;
return NextResponse.json({
presets: FAL_IMAGE_MODEL_PRESETS,
counts: {
imageEdit: FAL_IMAGE_MODEL_PRESETS.length,
imageEdit: imageEditCount,
total: FAL_IMAGE_MODEL_PRESETS.length,
},
});

View File

@ -1,4 +1,6 @@
export const FAL_IMAGE_BASE_URL = "https://fal.run";
export const FAL_NANO_BANANA_2_MODEL_ID = "fal-ai/nano-banana-2";
export const FAL_NANO_BANANA_2_EDIT_MODEL_ID = "fal-ai/nano-banana-2/edit";
export const FAL_GPT_IMAGE_2_EDIT_MODEL_ID = "openai/gpt-image-2/edit";
export interface FalImageModelPreset {
@ -14,6 +16,152 @@ export interface FalImageModelPreset {
}
export const FAL_IMAGE_MODEL_PRESETS: FalImageModelPreset[] = [
{
modelId: FAL_NANO_BANANA_2_MODEL_ID,
displayName: "FAL Nano Banana 2",
description:
"FAL 官方 Nano Banana 2 文生图模型。无参考图时用于生成图片;如传入参考图,系统会自动切换到官方 /edit 端点。",
supportsVision: true,
supportsImageGen: true,
capabilities: {
provider: "fal",
kind: "text-to-image",
endpoint: FAL_NANO_BANANA_2_MODEL_ID,
editEndpoint: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
apiFormat: "fal-nano-banana-2",
officialUrl: "https://fal.ai/models/fal-ai/nano-banana-2/api",
editOfficialUrl: "https://fal.ai/models/fal-ai/nano-banana-2/edit/api",
requiredFields: ["prompt"],
optionalFields: [
"num_images",
"aspect_ratio",
"output_format",
"safety_tolerance",
"resolution",
"limit_generations",
"enable_web_search",
"image_urls",
],
supportsMultiImageReference: true,
autoRouteEditEndpoint: true,
},
defaultParams: {
provider: "fal",
endpoint: FAL_NANO_BANANA_2_MODEL_ID,
editEndpoint: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
apiFormat: "fal-nano-banana-2",
imageSize: "1K",
aspectRatio: "auto",
outputFormat: "png",
safetyTolerance: "4",
limitGenerations: true,
},
imageGenConfig: {
maxImages: 4,
supportsSize: true,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: true,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: true,
supportedAspectRatios: [
"auto",
"21:9",
"16:9",
"3:2",
"4:3",
"5:4",
"1:1",
"4:5",
"3:4",
"2:3",
"9:16",
"4:1",
"1:4",
"8:1",
"1:8",
],
supportedImageSizes: ["0.5K", "1K", "2K", "4K"],
enableLoadingPlaceholder: true,
loadingColor: "purple",
creditCostPerImage: 0,
creditCostPerVideo: 0,
},
sortOrder: 10,
},
{
modelId: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
displayName: "FAL Nano Banana 2 Edit",
description:
"FAL 官方 Nano Banana 2 图像编辑模型,支持最多 14 张参考图和自然语言编辑。",
supportsVision: true,
supportsImageGen: true,
capabilities: {
provider: "fal",
kind: "image-edit",
endpoint: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
apiFormat: "fal-nano-banana-2-edit",
officialUrl: "https://fal.ai/models/fal-ai/nano-banana-2/edit/api",
requiredFields: ["prompt", "image_urls"],
optionalFields: [
"num_images",
"aspect_ratio",
"output_format",
"safety_tolerance",
"resolution",
"limit_generations",
"enable_web_search",
],
supportsMultiImageReference: true,
maxReferenceImages: 14,
},
defaultParams: {
provider: "fal",
endpoint: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
apiFormat: "fal-nano-banana-2-edit",
imageSize: "1K",
aspectRatio: "auto",
outputFormat: "png",
safetyTolerance: "4",
limitGenerations: true,
},
imageGenConfig: {
maxImages: 4,
supportsSize: true,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: true,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: true,
supportedAspectRatios: [
"auto",
"21:9",
"16:9",
"3:2",
"4:3",
"5:4",
"1:1",
"4:5",
"3:4",
"2:3",
"9:16",
"4:1",
"1:4",
"8:1",
"1:8",
],
supportedImageSizes: ["0.5K", "1K", "2K", "4K"],
enableLoadingPlaceholder: true,
loadingColor: "purple",
creditCostPerImage: 0,
creditCostPerVideo: 0,
},
sortOrder: 11,
},
{
modelId: FAL_GPT_IMAGE_2_EDIT_MODEL_ID,
displayName: "FAL GPT Image 2 Edit",

View File

@ -15,6 +15,7 @@ interface FalImageParams {
imageCount?: number;
outputFormat?: "png" | "jpg" | "jpeg" | "webp";
safetyTolerance?: string | number;
seed?: string | number;
enableWebSearch?: boolean;
quality?: string;
maskImageUrl?: string;
@ -32,6 +33,11 @@ const DEFAULT_RESOLUTION = "1K";
const DEFAULT_OUTPUT_FORMAT = "png";
const DEFAULT_SAFETY_TOLERANCE = "4";
const DEFAULT_TIMEOUT_MS = 180000;
const FAL_NANO_BANANA_MODEL_IDS = new Set([
"fal-ai/nano-banana",
"fal-ai/nano-banana-2",
"fal-ai/nano-banana-pro",
]);
const FAL_GPT_IMAGE_2_EDIT_MODEL_ID = "openai/gpt-image-2/edit";
const GPT_IMAGE_2_EDIT_IMAGE_SIZE_PRESETS = new Set([
"auto",
@ -156,6 +162,10 @@ function normalizeAspectRatio(aspectRatio?: string) {
"3:4",
"2:3",
"9:16",
"4:1",
"1:4",
"8:1",
"1:8",
].includes(normalized)
) {
return normalized === "auto" ? "auto" : ratio;
@ -167,7 +177,7 @@ function normalizeAspectRatio(aspectRatio?: string) {
function normalizeResolution(imageSize?: string) {
if (!imageSize) return DEFAULT_RESOLUTION;
const normalized = imageSize.trim().toUpperCase();
if (["1K", "2K", "4K"].includes(normalized)) return normalized;
if (["0.5K", "1K", "2K", "4K"].includes(normalized)) return normalized;
return DEFAULT_RESOLUTION;
}
@ -186,10 +196,28 @@ function normalizeSafetyTolerance(value?: string | number) {
return DEFAULT_SAFETY_TOLERANCE;
}
function normalizeSeed(value?: string | number) {
if (value === undefined || value === null || value === "") return undefined;
const seed = Number(value);
if (!Number.isInteger(seed) || seed < 0) return undefined;
return seed;
}
function isFalGptImage2EditModel(modelId: string) {
return modelId.trim().toLowerCase() === FAL_GPT_IMAGE_2_EDIT_MODEL_ID;
}
function normalizeFalEndpointId(modelId: string, hasInputImages: boolean) {
const endpoint = modelId.trim();
const normalized = endpoint.toLowerCase();
if (!hasInputImages) return endpoint;
if (normalized.endsWith("/edit")) return endpoint;
if (FAL_NANO_BANANA_MODEL_IDS.has(normalized)) {
return `${endpoint}/edit`;
}
return endpoint;
}
function normalizeGptImage2EditQuality(value?: string) {
if (!value) return "high";
const normalized = value.trim().toLowerCase();
@ -395,6 +423,26 @@ function formatMarkdown(images: UploadedImage[], resolution: string) {
.join("\n\n");
}
function summarizeFalError(error: any) {
const status = error?.status || error?.statusCode || error?.response?.status;
const body =
error?.body ||
error?.responseBody ||
error?.data ||
error?.response?.data ||
error?.cause?.data;
const message =
error?.message ||
(typeof body === "string" ? body : "") ||
"FAL request failed";
return {
name: error?.name,
status,
message,
body,
};
}
export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
return {
provider: "fal-image",
@ -410,9 +458,16 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
const resolution = normalizeResolution(options.imageParams?.imageSize);
const outputFormat = normalizeOutputFormat(options.imageParams?.outputFormat);
const safetyTolerance = normalizeSafetyTolerance(options.imageParams?.safetyTolerance);
const seed = normalizeSeed(options.imageParams?.seed);
const imageCount = Math.max(1, Math.min(6, Number(options.imageParams?.imageCount || 1)));
const activeModelId = options.overrideModelId || modelId;
const isGptImage2Edit = isFalGptImage2EditModel(activeModelId);
const falEndpointId = isGptImage2Edit
? activeModelId
: normalizeFalEndpointId(activeModelId, imageUrls.length > 0);
if (!isGptImage2Edit && falEndpointId.toLowerCase().endsWith("/edit") && imageUrls.length === 0) {
throw new Error("FAL 图片编辑模型需要至少一张可访问的参考图片 URL");
}
const gptImage2EditSize = normalizeGptImage2EditImageSize(
options.imageParams?.imageSize,
options.imageParams?.aspectRatio
@ -423,14 +478,16 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
console.log("[Fal Image] Submitting request", {
model: activeModelId,
endpoint: falEndpointId,
baseURL,
imageCount,
aspectRatio,
resolution: outputSizeLabel,
hasInputImages: imageUrls.length > 0,
});
const fal = await import("@fal-ai/serverless-client");
fal.config({
const { createFalClient } = await import("@fal-ai/client");
const fal = createFalClient({
credentials: normalizeFalCredentials(apiKey),
});
@ -458,6 +515,10 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
input.sync_mode = options.imageParams.syncMode;
}
if (seed !== undefined) {
input.seed = seed;
}
const openaiApiKey = String(options.imageParams?.openaiApiKey || "").trim();
if (openaiApiKey) {
input.openai_api_key = openaiApiKey;
@ -470,31 +531,42 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
resolution,
output_format: outputFormat,
safety_tolerance: safetyTolerance,
limit_generations: true,
};
if (imageUrls.length > 0) {
input.image_urls = imageUrls;
}
if (seed !== undefined) {
input.seed = seed;
}
if (typeof options.imageParams?.enableWebSearch === "boolean") {
input.enable_web_search = options.imageParams.enableWebSearch;
}
}
const result: any = await fal.subscribe(activeModelId, {
input,
logs: true,
timeout: DEFAULT_TIMEOUT_MS,
onQueueUpdate: (update: any) => {
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
for (const log of update.logs) {
if (log?.message) {
console.log(`[Fal Image][${activeModelId}]`, log.message);
let result: any;
try {
result = await fal.subscribe(falEndpointId, {
input,
logs: true,
timeout: DEFAULT_TIMEOUT_MS,
onQueueUpdate: (update: any) => {
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
for (const log of update.logs) {
if (log?.message) {
console.log(`[Fal Image][${falEndpointId}]`, log.message);
}
}
}
}
},
});
},
});
} catch (error) {
console.error("[Fal Image] Request failed:", summarizeFalError(error));
throw error;
}
let outputUrls = filterSourceImages(collectImageUrls(result), imageUrls);
outputUrls = await filterSourceImagesByContent(outputUrls, imageUrls);

View File

@ -33,6 +33,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.35.3",
"@drawnix/drawnix": "0.4.0-2",
"@fal-ai/client": "^1.10.0",
"@fal-ai/serverless-client": "^0.15.0",
"@icons-pack/react-simple-icons": "^13.7.0",
"@langchain/core": "^1.1.19",

13
pnpm-lock.yaml generated
View File

@ -47,6 +47,9 @@ importers:
'@drawnix/drawnix':
specifier: 0.4.0-2
version: 0.4.0-2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@fal-ai/client':
specifier: ^1.10.0
version: 1.10.0
'@fal-ai/serverless-client':
specifier: ^0.15.0
version: 0.15.0
@ -1323,6 +1326,10 @@ packages:
cpu: [x64]
os: [win32]
'@fal-ai/client@1.10.0':
resolution: {integrity: sha512-sQWnBc6cdIzK8nFybrVKal0rLeJS2vqrrNxx4Hcc0SorndkfkMXL3TIAiIfiF/AlZuVoRpazpNg6n8K81WHzOQ==}
engines: {node: '>=18.0.0'}
'@fal-ai/serverless-client@0.15.0':
resolution: {integrity: sha512-4Vuocu0342OijAN6xO/lwohDV7h90LbkTnOAEwH+pYvMFVC6RYmHS4GILc/wnOWBTw+iFlZFEKlljEVolkjVfg==}
engines: {node: '>=18.0.0'}
@ -7615,6 +7622,12 @@ snapshots:
'@esbuild/win32-x64@0.25.1':
optional: true
'@fal-ai/client@1.10.0':
dependencies:
'@msgpack/msgpack': 3.1.3
eventsource-parser: 1.1.2
robot3: 0.4.1
'@fal-ai/serverless-client@0.15.0':
dependencies:
'@msgpack/msgpack': 3.1.3