Skip to main content
Glama
imageGenerator.tsβ€’26.4 kB
/** * @license * Copyright 2025 Aeven * SPDX-License-Identifier: Apache-2.0 */ import { FileHandler } from "./fileHandler.js"; import { ImageGenerationRequest, ImageGenerationResponse, AuthConfig, StorySequenceArgs, } from "./types.js"; import { exec } from "child_process"; import { promisify } from "util"; import * as path from "path"; import * as fs from "fs"; import { config as loadEnv } from "dotenv"; import { fileURLToPath } from "url"; import { logger } from "./logger.js"; const execAsync = promisify(exec); const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); class OpenRouterApiError extends Error { constructor( message: string, public readonly status?: number, public readonly statusText?: string ) { super(message); this.name = "OpenRouterApiError"; } } interface OpenRouterImageData { b64_json?: string; base64?: string; url?: string; } interface OpenRouterOutputContent { type?: string; text?: string; data?: string; annotations?: unknown[]; image_base64?: string; result?: string; } interface OpenRouterOutputItem { type?: string; role?: string; status?: string; content?: OpenRouterOutputContent[]; result?: string; } interface OpenRouterImageResponse { data?: OpenRouterImageData[]; output?: OpenRouterOutputItem[]; error?: { message?: string; }; message?: string; } export class ImageGenerator { private static environmentHydrated = false; private readonly apiKey: string; private readonly baseUrl: string; private readonly modelName: string; private readonly referer: string; private readonly title: string; private readonly generationPath: string; private static readonly DEFAULT_FORMAT: "png" | "jpeg" = "png"; private static readonly DEFAULT_REFERER = "https://github.com/AevenAI/mcps/tree/main/nanobanana"; constructor(authConfig: AuthConfig) { this.apiKey = authConfig.apiKey; const env = process.env; this.baseUrl = env.MODEL_BASE_URL?.replace(/\/$/, "") || "https://openrouter.ai/api/v1"; this.modelName = env.MODEL_ID || "google/gemini-2.5-flash-image"; this.referer = env.MODEL_REFERER || ImageGenerator.DEFAULT_REFERER; this.title = env.MODEL_TITLE || "Nano Banana MCP Server"; this.generationPath = env.MODEL_GENERATE_PATH || "/responses"; } private buildHeaders(kind: "json" | "form"): Record<string, string> { const headers: Record<string, string> = { Authorization: `Bearer ${this.apiKey}`, Accept: "application/json", }; if (kind === "json") { headers["Content-Type"] = "application/json"; } if (this.referer) { headers["HTTP-Referer"] = this.referer; } headers["X-Title"] = this.title; return headers; } private async postJson<T>(pathName: string, body: unknown): Promise<T> { const response = await fetch(`${this.baseUrl}${pathName}`, { method: "POST", headers: this.buildHeaders("json"), body: JSON.stringify(body), }); return this.handleResponse<T>(response); } private async handleResponse<T>(response: Response): Promise<T> { const bodyText = await response.text(); if (!response.ok) { throw new OpenRouterApiError( this.formatErrorMessage(response, bodyText), response.status, response.statusText ); } try { return JSON.parse(bodyText) as T; } catch { const snippet = bodyText.slice(0, 500); throw new OpenRouterApiError( `OpenRouter returned non-JSON response (status ${response.status}). Body snippet: ${snippet}`, response.status, response.statusText ); } } private formatErrorMessage(response: Response, bodyText: string): string { let detail: string | undefined; try { const parsed = JSON.parse(bodyText) as OpenRouterImageResponse; detail = parsed?.error?.message || parsed?.message; } catch { detail = bodyText.trim(); } const prefix = `OpenRouter request failed with status ${response.status}${ response.statusText ? ` ${response.statusText}` : "" }`; if (!detail) { return prefix; } return `${prefix}: ${detail.slice(0, 500)}`; } private parseImageFromResponse( response: OpenRouterImageResponse ): string | null { if (!response) { return null; } const tryDecode = (value?: string | null): string | null => { if (!value) { return null; } if (value.startsWith("data:image")) { const commaIndex = value.indexOf(","); if (commaIndex !== -1) { const base64 = value.slice(commaIndex + 1); return this.isValidBase64ImageData(base64) ? base64 : null; } } return this.isValidBase64ImageData(value) ? value : null; }; if (response.output && response.output.length > 0) { for (const item of response.output) { if (item.type === "image_generation_call") { const direct = tryDecode(item.result); if (direct) { return direct; } if (item.content) { for (const part of item.content) { const contentImage = tryDecode(part.image_base64) || tryDecode(part.result) || tryDecode(part.data) || tryDecode(part.text); if (contentImage) { return contentImage; } } } } if (item.content) { for (const part of item.content) { const embedded = tryDecode(part.image_base64) || tryDecode(part.result) || tryDecode(part.data) || tryDecode(part.text); if (embedded) { return embedded; } } } } } if (response.data && response.data.length > 0) { for (const part of response.data) { const encoded = part.b64_json || part.base64 || (part.url ? part.url : undefined); if (!encoded) { continue; } if (encoded.startsWith("http")) { logger.debug( "Ignoring URL in OpenRouter response; direct download not supported yet." ); continue; } const legacy = tryDecode(encoded); if (legacy) { return legacy; } } } return null; } private async openImagePreview(filePath: string): Promise<void> { try { const platform = process.platform; let command: string; switch (platform) { case "darwin": command = `open "${filePath}"`; break; case "win32": command = `start "" "${filePath}"`; break; default: command = `xdg-open "${filePath}"`; break; } await execAsync(command); logger.debug(`Opened preview for: ${filePath}`); } catch (error: unknown) { logger.warn( `Failed to open preview for ${filePath}:`, error instanceof Error ? error.message : String(error) ); } } private shouldAutoPreview(request: ImageGenerationRequest): boolean { if (request.noPreview) { return false; } if (request.preview) { return true; } return false; } private async handlePreview( files: string[], request: ImageGenerationRequest ): Promise<void> { const shouldPreview = this.shouldAutoPreview(request); if (!shouldPreview || !files.length) { if (files.length > 1 && request.noPreview) { logger.debug( `Auto-preview disabled for ${files.length} images (--no-preview specified)` ); } return; } logger.debug( `${request.preview ? "Explicit" : "Auto"}-opening ${files.length} image(s) for preview` ); const previewPromises = files.map((file) => this.openImagePreview(file)); await Promise.all(previewPromises); } static validateAuthentication(): AuthConfig { ImageGenerator.ensureAuthenticationEnv(); if (process.env.MODEL_API_KEY) { logger.info("Found MODEL_API_KEY environment variable"); return { apiKey: process.env.MODEL_API_KEY, keyType: "MODEL_API_KEY" }; } throw new Error( "ERROR: No model API key found. Please set the MODEL_API_KEY environment variable.\n" + "For provider setup details, see your model host documentation (OpenRouter docs: https://openrouter.ai/docs#authenticate)." ); } private isValidBase64ImageData(data: string): boolean { if (!data || data.length < 100) { return false; } const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; if (!base64Regex.test(data)) { return false; } if (data.length < 1000) { logger.debug( "Skipping short data that may not be image:", data.length, "characters" ); return false; } return true; } private buildBatchPrompts(request: ImageGenerationRequest): string[] { const prompts: string[] = []; const basePrompt = request.prompt; if (!request.styles && !request.variations && !request.outputCount) { return [basePrompt]; } if (request.styles && request.styles.length > 0) { for (const style of request.styles) { prompts.push(`${basePrompt}, ${style} style`); } } if (request.variations && request.variations.length > 0) { const basePrompts = prompts.length > 0 ? prompts : [basePrompt]; const variationPrompts: string[] = []; for (const baseP of basePrompts) { for (const variation of request.variations) { switch (variation) { case "lighting": variationPrompts.push(`${baseP}, dramatic lighting`); variationPrompts.push(`${baseP}, soft lighting`); break; case "angle": variationPrompts.push(`${baseP}, from above`); variationPrompts.push(`${baseP}, close-up view`); break; case "color-palette": variationPrompts.push(`${baseP}, warm color palette`); variationPrompts.push(`${baseP}, cool color palette`); break; case "composition": variationPrompts.push(`${baseP}, centered composition`); variationPrompts.push(`${baseP}, rule of thirds composition`); break; case "mood": variationPrompts.push(`${baseP}, cheerful mood`); variationPrompts.push(`${baseP}, dramatic mood`); break; case "season": variationPrompts.push(`${baseP}, in spring`); variationPrompts.push(`${baseP}, in winter`); break; case "time-of-day": variationPrompts.push(`${baseP}, at sunrise`); variationPrompts.push(`${baseP}, at sunset`); break; default: variationPrompts.push(`${baseP}, ${variation}`); break; } } } if (variationPrompts.length > 0) { prompts.splice(0, prompts.length, ...variationPrompts); } } if ( prompts.length === 0 && request.outputCount && request.outputCount > 1 ) { for (let i = 0; i < request.outputCount; i++) { prompts.push(basePrompt); } } if (request.outputCount && prompts.length > request.outputCount) { prompts.splice(request.outputCount); } return prompts.length > 0 ? prompts : [basePrompt]; } private static ensureAuthenticationEnv(): void { if (ImageGenerator.environmentHydrated) { return; } ImageGenerator.environmentHydrated = true; ImageGenerator.applyEnvFromArgs(); if (!process.env.MODEL_API_KEY) { ImageGenerator.tryLoadEnvFiles(); } if (!process.env.MODEL_API_KEY) { const fallbackKeys = ["OPENROUTER_API_KEY", "OPENAI_API_KEY"]; for (const key of fallbackKeys) { const value = process.env[key]; if (value) { process.env.MODEL_API_KEY = value; logger.info( `Using ${key} environment variable as MODEL_API_KEY fallback` ); break; } } } } private static applyEnvFromArgs(): void { const args = process.argv.slice(2); for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === "--env") { const assignment = args[index + 1]; if (assignment) { ImageGenerator.assignEnvFromPair(assignment); index++; } } else if (arg.startsWith("--env=")) { const assignment = arg.slice("--env=".length); ImageGenerator.assignEnvFromPair(assignment); } } } private static assignEnvFromPair(pair: string): void { const separatorIndex = pair.indexOf("="); if (separatorIndex <= 0) { return; } const key = pair.slice(0, separatorIndex).trim(); const value = pair.slice(separatorIndex + 1).trim(); if (!key || !value) { return; } if (process.env[key] === undefined) { process.env[key] = value; if (key !== "MODEL_API_KEY") { logger.debug(`Loaded ${key} from CLI arguments`); } } } private static tryLoadEnvFiles(): void { const searchPaths = new Set<string>([ path.resolve(process.cwd(), ".env"), path.resolve(process.cwd(), "mcp-server/.env"), path.resolve(MODULE_DIR, "../.env"), path.resolve(MODULE_DIR, "../../.env"), ]); for (const candidate of searchPaths) { if (!fs.existsSync(candidate)) { continue; } const result = loadEnv({ path: candidate, override: false, quiet: true, }); if (result.error) { logger.warn( `Failed to load environment file ${candidate}:`, result.error.message ); continue; } if (process.env.MODEL_API_KEY) { logger.info( `Loaded MODEL_API_KEY from ${path.relative(process.cwd(), candidate)}` ); return; } } } private resolveFileFormat(request: ImageGenerationRequest): "png" | "jpeg" { return request.fileFormat || ImageGenerator.DEFAULT_FORMAT; } async generateTextToImage( request: ImageGenerationRequest ): Promise<ImageGenerationResponse> { try { const outputPath = FileHandler.ensureOutputDirectory(); const generatedFiles: string[] = []; const prompts = this.buildBatchPrompts(request); let firstError: string | null = null; const fileFormat = this.resolveFileFormat(request); logger.debug(`Generating ${prompts.length} image variation(s)`); for (let i = 0; i < prompts.length; i++) { const currentPrompt = prompts[i]; logger.debug( `Generating variation ${i + 1}/${prompts.length}:`, currentPrompt ); try { const payload: Record<string, unknown> = { model: this.modelName, input: [ { role: "user", content: [ { type: "input_text", text: currentPrompt, }, ], }, ], }; if (request.seed !== undefined) { payload.seed = request.seed; } const response = await this.postJson<OpenRouterImageResponse>( this.generationPath, payload ); const imageBase64 = this.parseImageFromResponse(response); if (imageBase64) { const filename = FileHandler.generateFilename( request.styles || request.variations ? currentPrompt : request.prompt, fileFormat, i ); const fullPath = await FileHandler.saveImageFromBase64( imageBase64, outputPath, filename ); generatedFiles.push(fullPath); logger.debug("Image saved to:", fullPath); } else { logger.warn("No valid image data found in OpenRouter response"); } } catch (error: unknown) { const errorMessage = this.handleApiError(error); if (!firstError) { firstError = errorMessage; } logger.warn(`Error generating variation ${i + 1}:`, errorMessage); if (errorMessage.toLowerCase().includes("authentication failed")) { return { success: false, message: "Image generation failed", error: errorMessage, }; } } } if (generatedFiles.length === 0) { return { success: false, message: "Failed to generate any images", error: firstError || "No image data returned from OpenRouter. Try adjusting your prompt.", }; } await this.handlePreview(generatedFiles, request); return { success: true, message: `Successfully generated ${generatedFiles.length} image variation(s)`, generatedFiles, }; } catch (error: unknown) { logger.error("Error in generateTextToImage:", error); return { success: false, message: "Failed to generate image", error: this.handleApiError(error), }; } } private detectMimeType(filename: string): string { const ext = path.extname(filename).toLowerCase(); switch (ext) { case ".jpg": case ".jpeg": return "image/jpeg"; case ".webp": return "image/webp"; case ".gif": return "image/gif"; default: return "image/png"; } } async generateStorySequence( request: ImageGenerationRequest, args?: StorySequenceArgs ): Promise<ImageGenerationResponse> { try { const outputPath = FileHandler.ensureOutputDirectory(); const generatedFiles: string[] = []; const steps = request.outputCount || 4; const type = args?.type || "story"; const style = args?.style || "consistent"; const transition = args?.transition || "smooth"; let firstError: string | null = null; logger.debug(`Generating ${steps}-step ${type} sequence`); for (let i = 0; i < steps; i++) { const stepNumber = i + 1; let stepPrompt = `${request.prompt}, step ${stepNumber} of ${steps}`; switch (type) { case "story": stepPrompt += `, narrative sequence, ${style} art style`; break; case "process": stepPrompt += `, procedural step, instructional illustration`; break; case "tutorial": stepPrompt += `, tutorial step, educational diagram`; break; case "timeline": stepPrompt += `, chronological progression, timeline visualization`; break; default: stepPrompt += `, ${type} sequence`; break; } if (i > 0) { stepPrompt += `, ${transition} transition from previous step`; } logger.debug(`Generating step ${stepNumber}: ${stepPrompt}`); try { const payload: Record<string, unknown> = { model: this.modelName, input: [ { role: "user", content: [ { type: "input_text", text: stepPrompt, }, ], }, ], }; if (request.seed !== undefined) { payload.seed = request.seed; } const response = await this.postJson<OpenRouterImageResponse>( this.generationPath, payload ); const imageBase64 = this.parseImageFromResponse(response); if (imageBase64) { const filename = FileHandler.generateFilename( `${type}step${stepNumber}${request.prompt}`, "png", 0 ); const fullPath = await FileHandler.saveImageFromBase64( imageBase64, outputPath, filename ); generatedFiles.push(fullPath); logger.debug(`Step ${stepNumber} saved to:`, fullPath); } else { logger.warn(`No image data returned for step ${stepNumber}`); } } catch (error: unknown) { const errorMessage = this.handleApiError(error); if (!firstError) { firstError = errorMessage; } logger.warn(`Error generating step ${stepNumber}:`, errorMessage); if (errorMessage.toLowerCase().includes("authentication failed")) { return { success: false, message: "Story generation failed", error: errorMessage, }; } } if (generatedFiles.length < stepNumber) { logger.warn( `Step ${stepNumber} failed to generate - no valid image data received` ); } } logger.debug( `Story generation completed. Generated ${generatedFiles.length} out of ${steps} requested images` ); if (generatedFiles.length === 0) { return { success: false, message: "Failed to generate any story sequence images", error: firstError || "No image data returned from OpenRouter. Try adjusting your prompt.", }; } await this.handlePreview(generatedFiles, request); const wasFullySuccessful = generatedFiles.length === steps; const successMessage = wasFullySuccessful ? `Successfully generated complete ${steps}-step ${type} sequence` : `Generated ${generatedFiles.length} out of ${steps} requested ${type} steps (${steps - generatedFiles.length} steps failed)`; return { success: true, message: successMessage, generatedFiles, }; } catch (error: unknown) { logger.error("Error in generateStorySequence:", error); return { success: false, message: `Failed to generate ${request.mode} sequence`, error: this.handleApiError(error), }; } } async editImage( request: ImageGenerationRequest ): Promise<ImageGenerationResponse> { try { if (!request.inputImage) { return { success: false, message: "Input image file is required for editing", error: "Missing inputImage parameter", }; } const fileResult = FileHandler.findInputFile(request.inputImage); if (!fileResult.found) { return { success: false, message: `Input image not found: ${request.inputImage}`, error: `Searched in: ${fileResult.searchedPaths.join(", ")}`, }; } const outputPath = FileHandler.ensureOutputDirectory(); const imageBase64 = await FileHandler.readImageAsBase64( fileResult.filePath! ); const fileName = path.basename(fileResult.filePath!); const mimeType = this.detectMimeType(fileName); const dataUrl = `data:${mimeType};base64,${imageBase64}`; const payload: Record<string, unknown> = { model: this.modelName, input: [ { role: "user", content: [ { type: "input_text", text: request.prompt, }, ], }, ], images: [dataUrl], }; if (request.seed !== undefined) { payload.seed = request.seed; } const response = await this.postJson<OpenRouterImageResponse>( this.generationPath, payload ); const imageBase64Result = this.parseImageFromResponse(response); if (!imageBase64Result) { return { success: false, message: `Failed to ${request.mode} image`, error: "No image data returned in OpenRouter response", }; } const filename = FileHandler.generateFilename( `${request.mode}_${request.prompt}`, "png", 0 ); const fullPath = await FileHandler.saveImageFromBase64( imageBase64Result, outputPath, filename ); await this.handlePreview([fullPath], request); return { success: true, message: `Successfully ${request.mode}d image`, generatedFiles: [fullPath], }; } catch (error: unknown) { logger.error(`Error in ${request.mode}Image:`, error); return { success: false, message: `Failed to ${request.mode} image`, error: this.handleApiError(error), }; } } private handleApiError(error: unknown): string { if (error instanceof OpenRouterApiError) { const status = error.status; if (status === 401) { return "Authentication failed: The provided model API key is invalid. Please check your MODEL_API_KEY value."; } if (status === 403) { return "Authentication failed: Access to the requested OpenRouter resource is forbidden. Ensure your API key has access to the google/gemini-2.5-flash-image model."; } if (status === 429) { return "OpenRouter rate limit reached. Please wait a moment before retrying or review your plan limits."; } if (status === 400) { return `The request was rejected by OpenRouter: ${error.message}`; } if (status && status >= 500) { return "OpenRouter encountered an internal error while processing the request. Please try again later."; } return error.message; } const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.toLowerCase().includes("fetch failed")) { return "Network error communicating with OpenRouter. Please check your internet connection and try again."; } return `An unexpected error occurred: ${errorMessage}`; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Aeven-AI/mcp-nanobanana'

If you have feedback or need assistance with the MCP directory API, please join our Discord server