/** * 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, generateWithGeminiVideo } from "./providers/gemini"; import { generateWithReplicate } from "./providers/replicate"; import { clearFalInputMappingCache as _clearFalInputMappingCache, generateWithFalQueue } from "./providers/fal"; import { submitKieTask } from "./providers/kie"; import { generateWithWaveSpeed } from "./providers/wavespeed"; import { generateWithNewApiWG } from "./providers/newapiwg"; import { submitPopiTask } from "./providers/popiserver"; import { getRequestToken, requireConfiguredValue, requireLogin } from "../_auth"; import { ApiError, apiErrorResponse } from "../_errors"; import { resolveUserGatewayApiKey } from "../_gatewayApiKey"; import { resolveTemporaryImageUrlsInArray } from "@/lib/images/temporaryUrls"; // Re-export for backward compatibility (test file imports from route) export const clearFalInputMappingCache = _clearFalInputMappingCache; export const maxDuration = 600; // 10 minute timeout for video generation polling 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; } interface ProviderConnectionParams { apiKey?: string; baseUrl?: string | null; } type RequiredSelectedModel = SelectedModel & { modelId: string; displayName: string; }; const providerConnectionConfig: Partial> = { newapiwg: { baseUrlHeader: "X-NewApiWG-Base-URL", baseUrlEnv: "NEWAPIWG_BASE_URL", }, gemini: { apiKeyHeader: "X-Gemini-API-Key", apiKeyEnv: "GEMINI_API_KEY", }, replicate: { apiKeyHeader: "X-Replicate-API-Key", apiKeyEnv: "REPLICATE_API_KEY", }, fal: { apiKeyHeader: "X-Fal-API-Key", apiKeyEnv: "FAL_API_KEY", }, kie: { apiKeyHeader: "X-Kie-Key", apiKeyEnv: "KIE_API_KEY", }, wavespeed: { apiKeyHeader: "X-WaveSpeed-Key", apiKeyEnv: "WAVESPEED_API_KEY", }, }; export function getConnectionParams(request: NextRequest, provider: ProviderType): ProviderConnectionParams { const config = providerConnectionConfig[provider]; if (!config) return {}; const apiKey = config.apiKeyHeader && config.apiKeyEnv ? request.headers.get(config.apiKeyHeader) || process.env[config.apiKeyEnv] || undefined : undefined; const baseUrl = config.baseUrlHeader && config.baseUrlEnv ? request.headers.get(config.baseUrlHeader) || process.env[config.baseUrlEnv] || null : undefined; return { apiKey, baseUrl }; } function requireSelectedModel( selectedModel: SelectedModel | undefined, providerName: string ): RequiredSelectedModel { if (!selectedModel?.modelId || !selectedModel?.displayName) { throw ApiError.badRequest( `selectedModel with modelId and displayName is required for ${providerName}` ); } return selectedModel as RequiredSelectedModel; } export function buildMediaResponse( output: { type: string; data: string; url?: string }, outputs?: Array<{ 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", }); } const image = output.data || output.url; const images = outputs ?.filter((item) => item.type === "image") .map((item) => item.data || item.url) .filter((item): item is string => typeof item === "string" && item.length > 0); return NextResponse.json({ success: true, image, ...(images && images.length > 1 ? { images } : {}), 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"]; } function mediaTypeForCapabilities(capabilities: string[] | undefined, fallback?: string): string { if (fallback) return fallback; if (capabilities?.some((capability) => capability.includes("audio"))) return "audio"; if (capabilities?.some((capability) => capability.includes("video"))) return "video"; if (capabilities?.some((capability) => capability.includes("3d"))) return "3d"; return "image"; } function mergeImageGenerationParameters( parameters: Record | undefined, aspectRatio: string | undefined, resolution: string | undefined ): Record | undefined { const merged = { ...(parameters || {}) }; if (aspectRatio) { merged.aspectRatio = aspectRatio; merged.aspect_ratio = aspectRatio; } if (resolution) { merged.resolution = resolution; merged.imageSize = resolution; } return Object.keys(merged).length > 0 ? merged : undefined; } async function readGenerateRequest(request: NextRequest): Promise { try { return await request.json(); } catch (error) { const message = error instanceof Error ? error.message : ""; if ( error instanceof SyntaxError || /json|unterminated string|unexpected end/i.test(message) ) { throw ApiError.badRequest( "生成请求过大或不完整:参考图数据可能在发送时被截断。请重试,或压缩参考图后再生成。" ); } throw error; } } 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 = await readGenerateRequest(request); const { images, prompt, model = "nano-banana-pro", aspectRatio, resolution, useGoogleSearch, useImageSearch, selectedModel, parameters, dynamicInputs, mediaType, } = body; const provider: ProviderType = selectedModel?.provider || "gemini"; const requestImages = provider === "newapiwg" ? images || [] : resolveTemporaryImageUrlsInArray(images); // 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 = requestImages.length > 0; const hasMediaInputs = dynamicInputs && Object.keys(dynamicInputs).some(key => key.includes('frame') || key.includes('image') || key.includes('video') || key.includes('audio') ); if (!hasPrompt && !hasImages && !hasMediaInputs) { throw ApiError.badRequest("Prompt or image input is required"); } // Determine which provider to use const login = provider !== "newapiwg" ? requireLogin(request) : null; const loginToken = login?.token ?? getRequestToken(request); console.log(`[API:${requestId}] Provider: ${provider}, Model: ${selectedModel?.modelId || model}`); // Route to appropriate provider if (provider === "popiserver") { const selected = requireSelectedModel(selectedModel, "Popi Models"); const selectedCapabilities = selected.capabilities as ModelCapability[] | undefined; const genInput: GenerationInput = { model: { id: selected.modelId, name: selected.displayName, provider: "popiserver", capabilities: selectedCapabilities || capabilitiesForMediaType(mediaType), description: null, metadata: selected.metadata, }, prompt: prompt || "", images: images ? [...images] : [], parameters: mergeImageGenerationParameters(parameters, aspectRatio, resolution), dynamicInputs, }; const { taskId } = await submitPopiTask(request, login!.token, genInput); return NextResponse.json({ success: true, polling: true, taskId, pollProvider: "popiserver", pollModelId: selected.modelId, pollModelName: selected.displayName, pollMediaType: mediaTypeForCapabilities(selected.capabilities, mediaType), }); } if (provider === "newapiwg") { const selected = requireSelectedModel(selectedModel, "NewApiWG"); const { baseUrl: newApiWGBaseUrl } = getConnectionParams(request, "newapiwg"); const apiKey = await resolveUserGatewayApiKey(request); const genInput: GenerationInput = { model: { id: selected.modelId, name: selected.displayName, provider: "newapiwg", capabilities: (selected.capabilities as ModelCapability[] | undefined) || capabilitiesForMediaType(mediaType), description: null, }, prompt: prompt || "", images: requestImages, parameters: mergeImageGenerationParameters(parameters, aspectRatio, resolution), dynamicInputs, }; const result = await generateWithNewApiWG(apiKey, newApiWGBaseUrl ?? null, genInput, loginToken); if (!result.success) { return NextResponse.json( { success: false, requestId, error: result.error || "Generation failed" }, { status: 500 } ); } const output = result.outputs?.[0]; if (!output?.data && !output?.url) { return NextResponse.json( { success: false, requestId, error: "No output in generation result" }, { status: 500 } ); } return buildMediaResponse(output); } if (provider === "replicate") { const selected = requireSelectedModel(selectedModel, "Replicate"); // User-provided key takes precedence over env variable const { apiKey: replicateApiKey } = getConnectionParams(request, "replicate"); const apiKey = requireConfiguredValue( replicateApiKey, "Replicate API key not configured. Add REPLICATE_API_KEY to .env.local or configure in Settings." ); // Keep Data URIs as-is since localhost URLs won't work (provider can't reach them) const processedImages = requestImages; // 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: selected.modelId, name: selected.displayName, provider: "replicate", capabilities: capabilitiesForMediaType(mediaType), description: null, }, prompt: prompt || "", images: processedImages, parameters, dynamicInputs: processedDynamicInputs, }; const result = await generateWithReplicate(requestId, apiKey, 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") { const selected = requireSelectedModel(selectedModel, "fal.ai"); // User-provided key takes precedence over env variable const { apiKey: falApiKey } = getConnectionParams(request, "fal"); 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 = requestImages; // 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: selected.modelId, name: selected.displayName, provider: "fal", capabilities: capabilitiesForMediaType(mediaType), description: null, }, prompt: prompt || "", images: processedImages, parameters, dynamicInputs: processedDynamicInputs, }; const result = await generateWithFalQueue(requestId, falApiKey || null, 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") { const selected = requireSelectedModel(selectedModel, "Kie.ai"); // User-provided key takes precedence over env variable const { apiKey: kieApiKey } = getConnectionParams(request, "kie"); const apiKey = requireConfiguredValue( kieApiKey, "Kie.ai API key not configured. Add KIE_API_KEY to .env.local or configure in Settings." ); // Process images - Kie requires URLs, we'll upload base64 images in generateWithKie const processedImages = requestImages; // 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: selected.modelId, name: selected.displayName, provider: "kie", capabilities: capabilitiesForMediaType(mediaType), description: null, }, prompt: prompt || "", images: processedImages, parameters, dynamicInputs: processedDynamicInputs, }; // Submit task and return immediately — client polls for completion try { const { taskId, isVeo } = await submitKieTask(requestId, apiKey, genInput); return NextResponse.json({ success: true, polling: true, taskId, pollProvider: 'kie', pollModelId: selected.modelId, pollModelName: selected.displayName, pollMediaType: mediaType || 'image', }); } catch (error) { return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "Task submission failed", }, { status: 500 } ); } } if (provider === "wavespeed") { const selected = requireSelectedModel(selectedModel, "WaveSpeed"); // User-provided key takes precedence over env variable const { apiKey: wavespeedApiKey } = getConnectionParams(request, "wavespeed"); const apiKey = requireConfiguredValue( wavespeedApiKey, "WaveSpeed API key not configured. Add WAVESPEED_API_KEY to .env.local or configure in Settings." ); // Keep Data URIs as-is since localhost URLs won't work const processedImages = requestImages; // 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: selected.modelId, name: selected.displayName, provider: "wavespeed", capabilities: capabilitiesForMediaType(mediaType), description: null, }, prompt: prompt || "", images: processedImages, parameters, dynamicInputs: processedDynamicInputs, }; const result = await generateWithWaveSpeed(requestId, apiKey, 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 { apiKey: geminiApiKey } = getConnectionParams(request, "gemini"); if (!geminiApiKey) { throw ApiError.config("API key not configured. Add GEMINI_API_KEY to .env.local or configure in Settings.", 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 } ); } // Check if this is a Veo video model request if (selectedModel?.modelId?.startsWith("veo-")) { // Merge negative prompt from dynamic inputs (connected handle) into parameters const veoParams = { ...(parameters || {}) }; if (dynamicInputs?.negative_prompt) { const neg = Array.isArray(dynamicInputs.negative_prompt) ? dynamicInputs.negative_prompt[0] : dynamicInputs.negative_prompt; if (neg) veoParams.negativePrompt = neg; } const result = await generateWithGeminiVideo( requestId, geminiApiKey, selectedModel.modelId, resolvedPrompt || "", requestImages, veoParams, ); if (!result.success) { return NextResponse.json( { success: false, error: result.error || "Video generation failed" }, { status: 500 } ); } const output = result.outputs?.[0]; if (!output?.data && !output?.url) { return NextResponse.json( { success: false, error: "No output in video generation result" }, { status: 500 } ); } return buildMediaResponse(output); } return await generateWithGemini( requestId, geminiApiKey, resolvedPrompt, requestImages, geminiModel, aspectRatio, resolution, useGoogleSearch, useImageSearch ); } catch (error) { if (error instanceof ApiError) { return apiErrorResponse(error, requestId); } // 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, requestId, error: errorMessage, }, { status: 500 } ); } }