Browse Source

refactor: extract Gemini provider from generate route

Move MODEL_MAP and generateWithGemini() into providers/gemini.ts.
Remove unused imports (GoogleGenAI, uploadImageForUrl, shouldUseImageUrl,
deleteImages, ProviderModel) from route.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
35fb94ed7b
  1. 188
      src/app/api/generate/providers/gemini.ts
  2. 180
      src/app/api/generate/route.ts

188
src/app/api/generate/providers/gemini.ts

@ -0,0 +1,188 @@
/**
* Gemini Provider for Generate API Route
*
* Handles image generation using Google's Gemini API models.
*/
import { NextResponse } from "next/server";
import { GoogleGenAI } from "@google/genai";
import { GenerateResponse, ModelType } from "@/types";
/**
* Map model types to Gemini model IDs
*/
export const MODEL_MAP: Record<ModelType, string> = {
"nano-banana": "gemini-2.5-flash-image",
"nano-banana-pro": "gemini-3-pro-image-preview",
};
/**
* Generate image using Gemini API (legacy/default path)
*/
export async function generateWithGemini(
requestId: string,
apiKey: string,
prompt: string,
images: string[],
model: ModelType,
aspectRatio?: string,
resolution?: string,
useGoogleSearch?: boolean
): Promise<NextResponse<GenerateResponse>> {
console.log(`[API:${requestId}] Gemini generation - Model: ${model}, Images: ${images?.length || 0}, Prompt: ${prompt?.length || 0} chars`);
// Extract base64 data and MIME types from data URLs
const imageData = (images || []).map((image, idx) => {
if (image.includes("base64,")) {
const [header, data] = image.split("base64,");
// Extract MIME type from header (e.g., "data:image/png;" -> "image/png")
const mimeMatch = header.match(/data:([^;]+)/);
const mimeType = mimeMatch ? mimeMatch[1] : "image/png";
console.log(`[API:${requestId}] Image ${idx + 1}: ${mimeType}, ${(data.length / 1024).toFixed(1)}KB`);
return { data, mimeType };
}
console.log(`[API:${requestId}] Image ${idx + 1}: raw, ${(image.length / 1024).toFixed(1)}KB`);
return { data: image, mimeType: "image/png" };
});
// Initialize Gemini client
const ai = new GoogleGenAI({ apiKey });
// Build request parts array with prompt and all images
const requestParts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [
{ text: prompt },
...imageData.map(({ data, mimeType }) => ({
inlineData: {
mimeType,
data,
},
})),
];
// Build config object based on model capabilities
const config: Record<string, unknown> = {
responseModalities: ["IMAGE", "TEXT"],
};
// Add imageConfig for both models (both support aspect ratio)
if (aspectRatio) {
config.imageConfig = {
aspectRatio,
};
}
// Add resolution only for Nano Banana Pro
if (model === "nano-banana-pro" && resolution) {
if (!config.imageConfig) {
config.imageConfig = {};
}
(config.imageConfig as Record<string, unknown>).imageSize = resolution;
}
// Add tools array for Google Search (only Nano Banana Pro)
const tools = [];
if (model === "nano-banana-pro" && useGoogleSearch) {
tools.push({ googleSearch: {} });
}
console.log(`[API:${requestId}] Config: ${JSON.stringify(config)}`);
// Make request to Gemini
const geminiStartTime = Date.now();
const response = await ai.models.generateContent({
model: MODEL_MAP[model],
contents: [
{
role: "user",
parts: requestParts,
},
],
config,
...(tools.length > 0 && { tools }),
});
const geminiDuration = Date.now() - geminiStartTime;
console.log(`[API:${requestId}] Gemini API completed in ${geminiDuration}ms`);
// Extract image from response
const candidates = response.candidates;
if (!candidates || candidates.length === 0) {
console.error(`[API:${requestId}] No candidates in Gemini response`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "No response from AI model",
},
{ status: 500 }
);
}
const parts = candidates[0].content?.parts;
console.log(`[API:${requestId}] Response parts: ${parts?.length || 0}`);
if (!parts) {
console.error(`[API:${requestId}] No parts in Gemini candidate content`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "No content in response",
},
{ status: 500 }
);
}
// Find image part in response
for (const part of parts) {
if (part.inlineData && part.inlineData.data) {
const mimeType = part.inlineData.mimeType || "image/png";
const imgData = part.inlineData.data;
const imageSizeKB = (imgData.length / 1024).toFixed(1);
console.log(`[API:${requestId}] Output image: ${mimeType}, ${imageSizeKB}KB`);
const dataUrl = `data:${mimeType};base64,${imgData}`;
const responsePayload = { success: true, image: dataUrl };
const responseSize = JSON.stringify(responsePayload).length;
const responseSizeMB = (responseSize / (1024 * 1024)).toFixed(2);
if (responseSize > 4.5 * 1024 * 1024) {
console.warn(`[API:${requestId}] Response size (${responseSizeMB}MB) approaching Next.js 5MB limit`);
}
console.log(`[API:${requestId}] SUCCESS - Returning ${responseSizeMB}MB payload`);
// Create response with explicit headers to handle large payloads
const resp = NextResponse.json<GenerateResponse>(responsePayload);
resp.headers.set('Content-Type', 'application/json');
resp.headers.set('Content-Length', responseSize.toString());
return resp;
}
}
// If no image found, check for text error
for (const part of parts) {
if (part.text) {
console.error(`[API:${requestId}] Gemini returned text instead of image: ${part.text.substring(0, 100)}`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: `Model returned text instead of image: ${part.text.substring(0, 200)}`,
},
{ status: 500 }
);
}
}
console.error(`[API:${requestId}] No image or text found in Gemini response`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "No image in response",
},
{ status: 500 }
);
}

180
src/app/api/generate/route.ts

@ -11,10 +11,8 @@
* Images are uploaded to fal CDN before submission to avoid payload size issues.
*/
import { NextRequest, NextResponse } from "next/server";
import { GoogleGenAI } from "@google/genai";
import { GenerateRequest, GenerateResponse, ModelType, SelectedModel, ProviderType } from "@/types";
import { GenerationInput, GenerationOutput, ProviderModel } from "@/lib/providers/types";
import { uploadImageForUrl, shouldUseImageUrl, deleteImages } from "@/lib/images";
import { GenerationInput, GenerationOutput } from "@/lib/providers/types";
import { validateMediaUrl } from "@/utils/urlValidation";
import {
INPUT_PATTERNS,
@ -24,15 +22,11 @@ import {
coerceParameterTypes,
getInputMappingFromSchema,
} from "./schemaUtils";
import { generateWithGemini } from "./providers/gemini";
export const maxDuration = 300; // 5 minute timeout (Vercel hobby plan limit)
export const dynamic = 'force-dynamic'; // Ensure this route is always dynamic
// Map model types to Gemini model IDs
const MODEL_MAP: Record<ModelType, string> = {
"nano-banana": "gemini-2.5-flash-image", // Updated to correct model name
"nano-banana-pro": "gemini-3-pro-image-preview",
};
/**
* Extended request format that supports both legacy and multi-provider requests
@ -44,176 +38,6 @@ interface MultiProviderGenerateRequest extends GenerateRequest {
dynamicInputs?: Record<string, string | string[]>;
}
/**
* Generate image using Gemini API (legacy/default path)
*/
async function generateWithGemini(
requestId: string,
apiKey: string,
prompt: string,
images: string[],
model: ModelType,
aspectRatio?: string,
resolution?: string,
useGoogleSearch?: boolean
): Promise<NextResponse<GenerateResponse>> {
console.log(`[API:${requestId}] Gemini generation - Model: ${model}, Images: ${images?.length || 0}, Prompt: ${prompt?.length || 0} chars`);
// Extract base64 data and MIME types from data URLs
const imageData = (images || []).map((image, idx) => {
if (image.includes("base64,")) {
const [header, data] = image.split("base64,");
// Extract MIME type from header (e.g., "data:image/png;" -> "image/png")
const mimeMatch = header.match(/data:([^;]+)/);
const mimeType = mimeMatch ? mimeMatch[1] : "image/png";
console.log(`[API:${requestId}] Image ${idx + 1}: ${mimeType}, ${(data.length / 1024).toFixed(1)}KB`);
return { data, mimeType };
}
console.log(`[API:${requestId}] Image ${idx + 1}: raw, ${(image.length / 1024).toFixed(1)}KB`);
return { data: image, mimeType: "image/png" };
});
// Initialize Gemini client
const ai = new GoogleGenAI({ apiKey });
// Build request parts array with prompt and all images
const requestParts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [
{ text: prompt },
...imageData.map(({ data, mimeType }) => ({
inlineData: {
mimeType,
data,
},
})),
];
// Build config object based on model capabilities
const config: Record<string, unknown> = {
responseModalities: ["IMAGE", "TEXT"],
};
// Add imageConfig for both models (both support aspect ratio)
if (aspectRatio) {
config.imageConfig = {
aspectRatio,
};
}
// Add resolution only for Nano Banana Pro
if (model === "nano-banana-pro" && resolution) {
if (!config.imageConfig) {
config.imageConfig = {};
}
(config.imageConfig as Record<string, unknown>).imageSize = resolution;
}
// Add tools array for Google Search (only Nano Banana Pro)
const tools = [];
if (model === "nano-banana-pro" && useGoogleSearch) {
tools.push({ googleSearch: {} });
}
console.log(`[API:${requestId}] Config: ${JSON.stringify(config)}`);
// Make request to Gemini
const geminiStartTime = Date.now();
const response = await ai.models.generateContent({
model: MODEL_MAP[model],
contents: [
{
role: "user",
parts: requestParts,
},
],
config,
...(tools.length > 0 && { tools }),
});
const geminiDuration = Date.now() - geminiStartTime;
console.log(`[API:${requestId}] Gemini API completed in ${geminiDuration}ms`);
// Extract image from response
const candidates = response.candidates;
if (!candidates || candidates.length === 0) {
console.error(`[API:${requestId}] No candidates in Gemini response`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "No response from AI model",
},
{ status: 500 }
);
}
const parts = candidates[0].content?.parts;
console.log(`[API:${requestId}] Response parts: ${parts?.length || 0}`);
if (!parts) {
console.error(`[API:${requestId}] No parts in Gemini candidate content`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "No content in response",
},
{ status: 500 }
);
}
// Find image part in response
for (const part of parts) {
if (part.inlineData && part.inlineData.data) {
const mimeType = part.inlineData.mimeType || "image/png";
const imgData = part.inlineData.data;
const imageSizeKB = (imgData.length / 1024).toFixed(1);
console.log(`[API:${requestId}] Output image: ${mimeType}, ${imageSizeKB}KB`);
const dataUrl = `data:${mimeType};base64,${imgData}`;
const responsePayload = { success: true, image: dataUrl };
const responseSize = JSON.stringify(responsePayload).length;
const responseSizeMB = (responseSize / (1024 * 1024)).toFixed(2);
if (responseSize > 4.5 * 1024 * 1024) {
console.warn(`[API:${requestId}] Response size (${responseSizeMB}MB) approaching Next.js 5MB limit`);
}
console.log(`[API:${requestId}] SUCCESS - Returning ${responseSizeMB}MB payload`);
// Create response with explicit headers to handle large payloads
const resp = NextResponse.json<GenerateResponse>(responsePayload);
resp.headers.set('Content-Type', 'application/json');
resp.headers.set('Content-Length', responseSize.toString());
return resp;
}
}
// If no image found, check for text error
for (const part of parts) {
if (part.text) {
console.error(`[API:${requestId}] Gemini returned text instead of image: ${part.text.substring(0, 100)}`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: `Model returned text instead of image: ${part.text.substring(0, 200)}`,
},
{ status: 500 }
);
}
}
console.error(`[API:${requestId}] No image or text found in Gemini response`);
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "No image in response",
},
{ status: 500 }
);
}
/**

Loading…
Cancel
Save