Browse Source
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
2 changed files with 190 additions and 178 deletions
@ -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 } |
||||
|
); |
||||
|
} |
||||
Loading…
Reference in new issue