You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

505 lines
16 KiB

/**
* Generate API Route
*
* TIMEOUT CONFIGURATION:
* - maxDuration: Only applies on Vercel, not locally
* - AbortSignal.timeout: Controls outgoing fetch to providers
* - For local development, server.requestTimeout must be set in server.js (Node.js default is 5 minutes)
*
* FAL.AI QUEUE API NOTE:
* Uses generateWithFalQueue with async queue submission + polling.
* Images are uploaded to fal CDN before submission to avoid payload size issues.
*/
import { NextRequest, NextResponse } from "next/server";
import { GenerateRequest, GenerateResponse, ModelType, SelectedModel, ProviderType } from "@/types";
import { GenerationInput, ModelCapability } from "@/lib/providers/types";
import { generateWithGemini } from "./providers/gemini";
import { generateWithReplicate } from "./providers/replicate";
import { clearFalInputMappingCache as _clearFalInputMappingCache, generateWithFalQueue } from "./providers/fal";
import { generateWithKie } from "./providers/kie";
import { generateWithWaveSpeed } from "./providers/wavespeed";
// Re-export for backward compatibility (test file imports from route)
export const clearFalInputMappingCache = _clearFalInputMappingCache;
export const maxDuration = 300; // 5 minute timeout (Vercel hobby plan limit)
export const dynamic = 'force-dynamic'; // Ensure this route is always dynamic
/**
* Extended request format that supports both legacy and multi-provider requests
*/
interface MultiProviderGenerateRequest extends GenerateRequest {
selectedModel?: SelectedModel;
parameters?: Record<string, unknown>;
/** Dynamic inputs from schema-based connections (e.g., image_url, tail_image_url, prompt) */
dynamicInputs?: Record<string, string | string[]>;
}
function buildMediaResponse(output: { type: string; data: string; url?: string }): NextResponse {
if (output.type === "3d") {
return NextResponse.json<GenerateResponse>({
success: true,
model3dUrl: output.url,
contentType: "3d",
});
}
if (output.type === "video") {
const isLarge = !output.data && output.url;
return NextResponse.json<GenerateResponse>({
success: true,
video: isLarge ? undefined : output.data,
videoUrl: isLarge ? output.url : undefined,
contentType: "video",
});
}
if (output.type === "audio") {
const isLarge = !output.data && output.url;
return NextResponse.json<GenerateResponse>({
success: true,
audio: isLarge ? undefined : output.data,
audioUrl: isLarge ? output.url : undefined,
contentType: "audio",
});
}
return NextResponse.json<GenerateResponse>({
success: true,
image: output.data,
contentType: "image",
});
}
function capabilitiesForMediaType(mediaType?: string): ModelCapability[] {
const map: Record<string, ModelCapability[]> = {
audio: ["text-to-audio"],
video: ["text-to-video"],
"3d": ["text-to-3d"],
};
return map[mediaType ?? ""] ?? ["text-to-image"];
}
export async function POST(request: NextRequest) {
const requestId = Math.random().toString(36).substring(7);
console.log(`\n[API:${requestId}] ========== NEW GENERATE REQUEST ==========`);
try {
const body: MultiProviderGenerateRequest = await request.json();
const {
images,
prompt,
model = "nano-banana-pro",
aspectRatio,
resolution,
useGoogleSearch,
selectedModel,
parameters,
dynamicInputs,
mediaType,
} = body;
// Prompt is required unless:
// - Provided via dynamicInputs
// - Images are provided (image-to-video/image-to-image models)
// - Dynamic inputs contain image frames (first_frame, last_frame, etc.)
const hasPrompt = prompt || (dynamicInputs && (
typeof dynamicInputs.prompt === 'string'
? dynamicInputs.prompt
: Array.isArray(dynamicInputs.prompt) && dynamicInputs.prompt.length > 0
));
const hasImages = (images && images.length > 0);
const hasImageInputs = dynamicInputs && Object.keys(dynamicInputs).some(key =>
key.includes('frame') || key.includes('image')
);
if (!hasPrompt && !hasImages && !hasImageInputs) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "Prompt or image input is required",
},
{ status: 400 }
);
}
// Determine which provider to use
const provider: ProviderType = selectedModel?.provider || "gemini";
console.log(`[API:${requestId}] Provider: ${provider}, Model: ${selectedModel?.modelId || model}`);
// Route to appropriate provider
if (provider === "replicate") {
// User-provided key takes precedence over env variable
const replicateApiKey = request.headers.get("X-Replicate-API-Key") || process.env.REPLICATE_API_KEY;
if (!replicateApiKey) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "Replicate API key not configured. Add REPLICATE_API_KEY to .env.local or configure in Settings.",
},
{ status: 401 }
);
}
// Keep Data URIs as-is since localhost URLs won't work (provider can't reach them)
const processedImages: string[] = images ? [...images] : [];
// Process dynamicInputs: filter empty values, keep Data URIs
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
if (dynamicInputs) {
processedDynamicInputs = {};
for (const key of Object.keys(dynamicInputs)) {
const value = dynamicInputs[key];
// Skip empty/null/undefined values (arrays pass through)
if (value === null || value === undefined || value === '') {
continue;
}
// Keep the value as-is (Data URIs work with Replicate)
processedDynamicInputs[key] = value;
}
}
// Build generation input
const genInput: GenerationInput = {
model: {
id: selectedModel!.modelId,
name: selectedModel!.displayName,
provider: "replicate",
capabilities: capabilitiesForMediaType(mediaType),
description: null,
},
prompt: prompt || "",
images: processedImages,
parameters,
dynamicInputs: processedDynamicInputs,
};
const result = await generateWithReplicate(requestId, replicateApiKey, genInput);
if (!result.success) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: result.error || "Generation failed",
},
{ status: 500 }
);
}
// Return first output
const output = result.outputs?.[0];
if (!output?.data && !output?.url) {
return NextResponse.json<GenerateResponse>(
{ success: false, error: "No output in generation result" },
{ status: 500 }
);
}
return buildMediaResponse(output);
}
if (provider === "fal") {
// User-provided key takes precedence over env variable
const falApiKey = request.headers.get("X-Fal-API-Key") || process.env.FAL_API_KEY || null;
if (!falApiKey) {
console.warn(`[API:${requestId}] No FAL API key configured. Proceeding without auth (rate-limited).`);
}
// Pass images as-is; generateWithFalQueue uploads base64 to CDN internally
const processedImages: string[] = images ? [...images] : [];
// Process dynamicInputs: filter empty values
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
if (dynamicInputs) {
processedDynamicInputs = {};
for (const key of Object.keys(dynamicInputs)) {
const value = dynamicInputs[key];
// Skip empty/null/undefined values (arrays pass through)
if (value === null || value === undefined || value === '') {
continue;
}
// Keep the value as-is; CDN upload happens in generateWithFalQueue
processedDynamicInputs[key] = value;
}
}
// Build generation input
const genInput: GenerationInput = {
model: {
id: selectedModel!.modelId,
name: selectedModel!.displayName,
provider: "fal",
capabilities: capabilitiesForMediaType(mediaType),
description: null,
},
prompt: prompt || "",
images: processedImages,
parameters,
dynamicInputs: processedDynamicInputs,
};
const result = await generateWithFalQueue(requestId, falApiKey, genInput);
if (!result.success) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: result.error || "Generation failed",
},
{ status: 500 }
);
}
// Return first output
const output = result.outputs?.[0];
if (!output?.data && !output?.url) {
return NextResponse.json<GenerateResponse>(
{ success: false, error: "No output in generation result" },
{ status: 500 }
);
}
return buildMediaResponse(output);
}
if (provider === "kie") {
// User-provided key takes precedence over env variable
const kieApiKey = request.headers.get("X-Kie-Key") || process.env.KIE_API_KEY;
if (!kieApiKey) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "Kie.ai API key not configured. Add KIE_API_KEY to .env.local or configure in Settings.",
},
{ status: 401 }
);
}
// Process images - Kie requires URLs, we'll upload base64 images in generateWithKie
const processedImages: string[] = images ? [...images] : [];
// Process dynamicInputs: filter empty values
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
if (dynamicInputs) {
processedDynamicInputs = {};
for (const key of Object.keys(dynamicInputs)) {
const value = dynamicInputs[key];
// Skip empty/null/undefined values
if (value === null || value === undefined || value === '') {
continue;
}
processedDynamicInputs[key] = value;
}
}
// Build generation input
const genInput: GenerationInput = {
model: {
id: selectedModel!.modelId,
name: selectedModel!.displayName,
provider: "kie",
capabilities: capabilitiesForMediaType(mediaType),
description: null,
},
prompt: prompt || "",
images: processedImages,
parameters,
dynamicInputs: processedDynamicInputs,
};
const result = await generateWithKie(requestId, kieApiKey, genInput);
if (!result.success) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: result.error || "Generation failed",
},
{ status: 500 }
);
}
// Return first output
const output = result.outputs?.[0];
if (!output?.data && !output?.url) {
return NextResponse.json<GenerateResponse>(
{ success: false, error: "No output in generation result" },
{ status: 500 }
);
}
return buildMediaResponse(output);
}
if (provider === "wavespeed") {
// User-provided key takes precedence over env variable
const wavespeedApiKey = request.headers.get("X-WaveSpeed-Key") || process.env.WAVESPEED_API_KEY;
if (!wavespeedApiKey) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "WaveSpeed API key not configured. Add WAVESPEED_API_KEY to .env.local or configure in Settings.",
},
{ status: 401 }
);
}
// Keep Data URIs as-is since localhost URLs won't work
const processedImages: string[] = images ? [...images] : [];
// Process dynamicInputs: filter empty values
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
if (dynamicInputs) {
processedDynamicInputs = {};
for (const key of Object.keys(dynamicInputs)) {
const value = dynamicInputs[key];
// Skip empty/null/undefined values
if (value === null || value === undefined || value === '') {
continue;
}
processedDynamicInputs[key] = value;
}
}
// Build generation input
const genInput: GenerationInput = {
model: {
id: selectedModel!.modelId,
name: selectedModel!.displayName,
provider: "wavespeed",
capabilities: capabilitiesForMediaType(mediaType),
description: null,
},
prompt: prompt || "",
images: processedImages,
parameters,
dynamicInputs: processedDynamicInputs,
};
const result = await generateWithWaveSpeed(requestId, wavespeedApiKey, genInput);
if (!result.success) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: result.error || "Generation failed",
},
{ status: 500 }
);
}
// Return first output
const output = result.outputs?.[0];
if (!output?.data && !output?.url) {
return NextResponse.json<GenerateResponse>(
{ success: false, error: "No output in generation result" },
{ status: 500 }
);
}
return buildMediaResponse(output);
}
// Default: Use Gemini
// User-provided key (from settings) takes precedence over env variable
const geminiApiKey = request.headers.get("X-Gemini-API-Key") || process.env.GEMINI_API_KEY;
if (!geminiApiKey) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "API key not configured. Add GEMINI_API_KEY to .env.local or configure in Settings.",
},
{ status: 500 }
);
}
// Use selectedModel.modelId if available (new format), fallback to legacy model field
const geminiModel = (selectedModel?.modelId as ModelType) || model;
// Resolve prompt: use top-level prompt, fall back to dynamicInputs.prompt
// This handles cases where the prompt arrives via dynamicInputs instead of top-level
let resolvedPrompt = prompt;
if (!resolvedPrompt && dynamicInputs?.prompt) {
resolvedPrompt = Array.isArray(dynamicInputs.prompt)
? dynamicInputs.prompt[0]
: dynamicInputs.prompt;
}
// Validate: if a prompt was provided but isn't a string (corrupted data), return clear error
// If no prompt provided but images exist, that's valid (image-to-image)
if (resolvedPrompt !== undefined && resolvedPrompt !== null && typeof resolvedPrompt !== 'string') {
return NextResponse.json<GenerateResponse>(
{ success: false, error: "prompt must be a string" },
{ status: 400 }
);
}
return await generateWithGemini(
requestId,
geminiApiKey,
resolvedPrompt,
images || [],
geminiModel,
aspectRatio,
resolution,
useGoogleSearch
);
} catch (error) {
// Extract error information
let errorMessage = "Generation failed";
let errorDetails = "";
if (error instanceof Error) {
errorMessage = error.message;
if ("cause" in error && error.cause) {
errorDetails = JSON.stringify(error.cause);
}
}
// Try to extract more details from API errors
if (error && typeof error === "object") {
const apiError = error as Record<string, unknown>;
if (apiError.status) {
errorDetails += ` Status: ${apiError.status}`;
}
if (apiError.statusText) {
errorDetails += ` ${apiError.statusText}`;
}
}
// Handle rate limiting
if (errorMessage.includes("429")) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "Rate limit reached. Please wait and try again.",
},
{ status: 429 }
);
}
console.error(`[API:${requestId}] Generation error: ${errorMessage}${errorDetails ? ` (${errorDetails.substring(0, 200)})` : ""}`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: errorMessage,
},
{ status: 500 }
);
}
}