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.
 
 

728 lines
24 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, 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<string, unknown>;
/** Dynamic inputs from schema-based connections (e.g., image_url, tail_image_url, prompt) */
dynamicInputs?: Record<string, string | string[]>;
}
interface ProviderConnectionParams {
apiKey?: string;
baseUrl?: string | null;
}
type RequiredSelectedModel = SelectedModel & {
modelId: string;
displayName: string;
};
const providerConnectionConfig: Partial<Record<ProviderType, {
apiKeyHeader?: string;
apiKeyEnv?: string;
baseUrlHeader?: string;
baseUrlEnv?: string;
}>> = {
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<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",
});
}
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<GenerateResponse>({
success: true,
image,
...(images && images.length > 1 ? { images } : {}),
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"];
}
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<string, unknown> | undefined,
aspectRatio: string | undefined,
resolution: string | undefined
): Record<string, unknown> | 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<MultiProviderGenerateRequest> {
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<GenerateResponse>({
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<GenerateResponse>(
{ success: false, requestId, error: result.error || "Generation failed" },
{ status: 500 }
);
}
const output = result.outputs?.[0];
if (!output?.data && !output?.url) {
return NextResponse.json<GenerateResponse>(
{ 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<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: 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<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") {
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<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: 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<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") {
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<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: 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<GenerateResponse>({
success: true,
polling: true,
taskId,
pollProvider: 'kie',
pollModelId: selected.modelId,
pollModelName: selected.displayName,
pollMediaType: mediaType || 'image',
});
} catch (error) {
return NextResponse.json<GenerateResponse>(
{
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<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: 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<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 { 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<GenerateResponse>(
{ 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<GenerateResponse>(
{ success: false, error: result.error || "Video generation failed" },
{ status: 500 }
);
}
const output = result.outputs?.[0];
if (!output?.data && !output?.url) {
return NextResponse.json<GenerateResponse>(
{ 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<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,
requestId,
error: errorMessage,
},
{ status: 500 }
);
}
}