/** * 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; /** Dynamic inputs from schema-based connections (e.g., image_url, tail_image_url, prompt) */ dynamicInputs?: Record; } function buildMediaResponse(output: { type: string; data: string; url?: string }): NextResponse { if (output.type === "3d") { return NextResponse.json({ success: true, model3dUrl: output.url, contentType: "3d", }); } if (output.type === "video") { const isLarge = !output.data && output.url; return NextResponse.json({ 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({ success: true, audio: isLarge ? undefined : output.data, audioUrl: isLarge ? output.url : undefined, contentType: "audio", }); } return NextResponse.json({ success: true, image: output.data, contentType: "image", }); } function capabilitiesForMediaType(mediaType?: string): ModelCapability[] { const map: Record = { 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( { 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( { 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 | 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( { 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( { 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 | 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( { 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( { 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( { 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 | 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( { 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( { 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( { 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 | 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( { 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( { 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( { 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( { 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; if (apiError.status) { errorDetails += ` Status: ${apiError.status}`; } if (apiError.statusText) { errorDetails += ` ${apiError.statusText}`; } } // Handle rate limiting if (errorMessage.includes("429")) { return NextResponse.json( { 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( { success: false, error: errorMessage, }, { status: 500 } ); } }