Files
openmaic/OpenMAIC/lib/media/adapters/nano-banana-adapter.ts
T

156 lines
4.3 KiB
TypeScript

/**
* Nano Banana / Gemini Native Image Generation Adapter
*
* Uses Google Gemini's native image generation capability.
* Endpoint: https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
*
* Supported models:
* - gemini-3.1-flash-image-preview (Nano Banana 2 — latest, fastest)
* - gemini-3-pro-image-preview (Nano Banana Pro — highest quality)
* - gemini-2.5-flash-image (Nano Banana — original)
*
* Authentication: x-goog-api-key header
*
* API docs: https://ai.google.dev/gemini-api/docs/image-generation
*/
import type {
ImageGenerationConfig,
ImageGenerationOptions,
ImageGenerationResult,
} from '../types';
const DEFAULT_MODEL = 'gemini-2.5-flash-image';
const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com';
interface GeminiPart {
text?: string;
inlineData?: {
mimeType: string;
data: string;
};
}
interface GeminiResponse {
candidates?: Array<{
content?: {
parts?: GeminiPart[];
};
}>;
error?: {
code: number;
message: string;
status: string;
};
}
/**
* Lightweight connectivity test — validates API key by fetching model info.
* Uses GET /v1beta/models/{model} which does not trigger generation.
*/
export async function testNanoBananaConnectivity(
config: ImageGenerationConfig,
): Promise<{ success: boolean; message: string }> {
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
const model = config.model || DEFAULT_MODEL;
const url = `${baseUrl}/v1beta/models`;
// Try ?key= query param first (direct Google API), fall back to x-goog-api-key header (proxy)
let response: Response | null = null;
try {
response = await fetch(`${url}?key=${config.apiKey}`, {
method: 'GET',
redirect: 'manual',
});
} catch {
// Direct API unreachable, try header auth
}
if (!response || !response.ok) {
try {
response = await fetch(url, {
method: 'GET',
redirect: 'manual',
headers: { 'x-goog-api-key': config.apiKey },
});
} catch (_err) {
return {
success: false,
message: `Network error: unable to reach ${baseUrl}. Check your Base URL and network connection.`,
};
}
}
if (response.ok) {
return { success: true, message: `Connected to Nano Banana (${model})` };
}
// Parse error body for user-friendly message
const text = await response.text().catch(() => '');
if (response.status === 400 || response.status === 401 || response.status === 403) {
return {
success: false,
message: `Invalid API key or unauthorized (${response.status}). Check your API Key and Base URL match the same provider.`,
};
}
return {
success: false,
message: `Nano Banana connectivity failed (${response.status}): ${text}`,
};
}
export async function generateWithNanoBanana(
config: ImageGenerationConfig,
options: ImageGenerationOptions,
): Promise<ImageGenerationResult> {
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
const model = config.model || DEFAULT_MODEL;
const response = await fetch(`${baseUrl}/v1beta/models/${model}:generateContent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': config.apiKey,
},
body: JSON.stringify({
contents: [
{
parts: [{ text: options.prompt }],
},
],
generationConfig: {
responseModalities: ['IMAGE'],
},
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Gemini image generation failed (${response.status}): ${text}`);
}
const data: GeminiResponse = await response.json();
if (data.error) {
throw new Error(`Gemini error: ${data.error.code} - ${data.error.message}`);
}
const parts = data.candidates?.[0]?.content?.parts;
if (!parts || parts.length === 0) {
throw new Error('Gemini returned empty response');
}
// Find the image part (inlineData with base64)
const imagePart = parts.find((p) => p.inlineData);
if (!imagePart?.inlineData) {
// Might have returned text only (e.g. if prompt was rejected)
const textPart = parts.find((p) => p.text);
throw new Error(`Gemini did not return an image. Response text: ${textPart?.text || 'none'}`);
}
return {
base64: imagePart.inlineData.data,
width: options.width || 1024,
height: options.height || 1024,
};
}