import { randomUUID } from "crypto"; /** * Image Generation Handler * * Handles POST /v1/images/generations requests. * Proxies to upstream image generation providers using OpenAI-compatible format. * * Request format (OpenAI-compatible): * { * "model": "openai/gpt-image-2", * "prompt": "a beautiful sunset over mountains", * "n": 1, * "size": "1024x1024", * "quality": "standard", // optional: "standard" | "hd" * "response_format": "url" // optional: "url" | "b64_json" * } */ import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; import { HTTP_STATUS } from "../config/constants.ts"; import { applyAntigravityClientProfileHeaders } from "../services/antigravityClientProfile.ts"; import { getAntigravityEnvelopeUserAgent } from "../services/antigravityIdentity.ts"; import { kieExecutor } from "../executors/kie.ts"; import { mapImageSize } from "../translator/image/sizeMapper.ts"; import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; import { ChatGptWebExecutor } from "../executors/chatgpt-web.ts"; import { getChatGptImage, findChatGptImageBySha256 } from "../services/chatgptImageCache.ts"; import { createHash } from "node:crypto"; import { saveCallLog } from "@/lib/usageDb"; import { sleep } from "../utils/sleep.ts"; import { getKieErrorMessage, getKieErrorStatus, isJsonObject, parseKieResultJson, } from "../utils/kieTask.ts"; import { submitComfyWorkflow, pollComfyResult, fetchComfyOutput, extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts"; // --- Per-provider handlers (extracted to co-located files in PR-#4582-batch) --- // Imported locally so internal callers (handleImageGeneration / handleImageEdit) // resolve to a real binding. extractMarkdownImageUrls + CHATGPT_WEB_IMAGE_ID_RE // are still used by handleImageEdit below, so they are imported (not re-defined). import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts"; import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts"; import { handleHuggingFaceImageGeneration } from "./imageGeneration/providers/huggingface.ts"; import { handleComfyUIImageGeneration } from "./imageGeneration/providers/comfyUI.ts"; import { handleImagen3ImageGeneration } from "./imageGeneration/providers/imagen3.ts"; import { handleIdeogramImageGeneration } from "./imageGeneration/providers/ideogram.ts"; import { handleHaiperImageGeneration } from "./imageGeneration/providers/haiper.ts"; import { handleLeonardoImageGeneration } from "./imageGeneration/providers/leonardo.ts"; import { handleChatGptWebImageGeneration, extractMarkdownImageUrls, CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; interface KieImageOptions { model: string; provider: string; providerConfig: { baseUrl: string; statusUrl?: string; }; body: Record & { prompt?: unknown; size?: unknown; n?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown; }; credentials?: { apiKey?: string; accessToken?: string; } | null; log?: { info: (scope: string, message: string) => void; error: (scope: string, message: string) => void; } | null; } const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ "black-forest-labs/FLUX.2-max", "black-forest-labs/FLUX.2-pro", "black-forest-labs/FLUX.2-flex", "black-forest-labs/FLUX.2-dev", "openai/gpt-image-1.5", "Wan-AI/Wan2.6-image", "Qwen/Qwen-Image-2.0-Pro", "Qwen/Qwen-Image-2.0", "google/flash-image-3.1", "google/gemini-3-pro-image", "flux-kontext-max", "flux-kontext", "flux-kontext-pro", "qwen-image", ]); const IMAGE_ASPECT_RATIO_PATTERN = /^\d+:\d+$/; /** * Resolve the upstream images endpoint for a custom (OpenAI-compatible) image * provider node (#3205). * * Custom provider nodes store their base URL the same way the chat path does: * in `credentials.providerSpecificData.baseUrl` (e.g. `https://example.com/v1`), * NOT as a top-level `credentials.baseUrl`. Older callers may still pass a * top-level `baseUrl`, so we honor that as a secondary source. When neither is * present we fall back to `fallback` (the built-in Gemini OpenAI endpoint). * * Resolution order: providerSpecificData.baseUrl → credentials.baseUrl → fallback. * * A node base URL like `https://example.com/v1` is normalized and the * OpenAI-compatible `/images/generations` path appended (mirroring * `buildOpenAICompatibleUrl` in services/provider.ts). A node URL that already * ends in `/images/generations` is returned as-is (no double-append). The * `fallback` value is assumed to already be a complete URL and is returned * verbatim. */ export function resolveImageBaseUrl( credentials: | { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, fallback: string, endpoint: "generations" | "edits" = "generations" ): string { const psd = credentials?.providerSpecificData; const psdBaseUrl = psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() ? psd.baseUrl.trim() : null; const topLevelBaseUrl = typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() ? credentials.baseUrl.trim() : null; const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; if (!nodeBaseUrl) return fallback; // A single configured node serves both image routes: honor a base URL that already // points at the requested OpenAI image path, and rewrite one that points at the other // image endpoint (e.g. `.../images/generations` requested for edits) (#3214/#3215). const suffix = `/images/${endpoint}`; // Trim trailing slashes without a backtracking-prone regex (`/\/+$/` is a // polynomial-ReDoS pattern on long runs of "/" — CodeQL js/polynomial-redos). let normalized = nodeBaseUrl; while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); if (normalized.endsWith(suffix)) return normalized; const stripped = normalized.replace(/\/images\/(?:generations|edits)$/, ""); return `${stripped}${suffix}`; } function normalizeImageAspectRatio(value: unknown, fallbackSize: unknown): string { if (typeof value === "string") { const trimmedValue = value.trim(); if (IMAGE_ASPECT_RATIO_PATTERN.test(trimmedValue)) return trimmedValue; } return mapImageSize(typeof fallbackSize === "string" ? fallbackSize : null); } function parseJsonOrNull(value: string): unknown | null { try { return JSON.parse(value); } catch { return null; } } function sanitizeImageProviderError(errorText: string): unknown { const parsed = parseJsonOrNull(errorText); if (parsed !== null) { return sanitizeUpstreamDetails(parsed) || sanitizeErrorMessage(errorText); } return sanitizeErrorMessage(errorText); } const BFL_MODEL_ENDPOINTS = { "flux-2-max": "/v1/flux-2-max", "flux-2-pro": "/v1/flux-2-pro", "flux-2-flex": "/v1/flux-2-flex", "flux-2-klein-9b": "/v1/flux-2-klein-9b", "flux-2-klein-4b": "/v1/flux-2-klein-4b", "flux-kontext-pro": "/v1/flux-kontext-pro", "flux-kontext-max": "/v1/flux-kontext-max", "flux-pro-1.1": "/v1/flux-pro-1.1", "flux-pro-1.1-ultra": "/v1/flux-pro-1.1-ultra", "flux-dev": "/v1/flux-dev", "flux-pro": "/v1/flux-pro", }; const BFL_EDIT_MODELS = new Set([ "flux-2-max", "flux-2-pro", "flux-2-flex", "flux-kontext-pro", "flux-kontext-max", ]); const BFL_FAILURE_STATUSES = new Set(["Error", "Failed", "Content Moderated", "Request Moderated"]); function formatImageProviderError(err) { const sanitized = sanitizeErrorMessage(err); const message = (sanitized || "").replace(/^Error:\s*/i, "").trim(); return message ? `Image provider error: ${message}` : "Image provider error"; } const STABILITY_GENERATION_ENDPOINTS = { "sd3.5-large": "/v2beta/stable-image/generate/sd3", "sd3.5-large-turbo": "/v2beta/stable-image/generate/sd3", "sd3.5-medium": "/v2beta/stable-image/generate/sd3", "sd3.5-flash": "/v2beta/stable-image/generate/sd3", "stable-image-ultra": "/v2beta/stable-image/generate/ultra", "stable-image-core": "/v2beta/stable-image/generate/core", }; const STABILITY_EDIT_ENDPOINTS = { inpaint: "/v2beta/stable-image/edit/inpaint", outpaint: "/v2beta/stable-image/edit/outpaint", erase: "/v2beta/stable-image/edit/erase", "search-and-replace": "/v2beta/stable-image/edit/search-and-replace", "search-and-recolor": "/v2beta/stable-image/edit/search-and-recolor", "remove-background": "/v2beta/stable-image/edit/remove-background", "replace-background-and-relight": "/v2beta/stable-image/edit/replace-background-and-relight", fast: "/v2beta/stable-image/upscale/fast", conservative: "/v2beta/stable-image/upscale/conservative", creative: "/v2beta/stable-image/upscale/creative", sketch: "/v2beta/stable-image/control/sketch", structure: "/v2beta/stable-image/control/structure", style: "/v2beta/stable-image/control/style", "style-transfer": "/v2beta/stable-image/control/style-transfer", }; const STABILITY_CONTROL_MODELS = new Set(["sketch", "structure", "style", "style-transfer"]); function appendOptionalFormValue(formData, key, value) { if (value === undefined || value === null || value === "") return; formData.append(key, String(value)); } function appendImageFormValue(formData, key, source, filename) { formData.append( key, new Blob([source.buffer], { type: source.contentType || "application/octet-stream", }), filename ); } const FAL_PRESET_SIZES = { "1024x1024": "square_hd", "512x512": "square", "1792x1024": "landscape_16_9", "1024x1792": "portrait_16_9", "1024x768": "landscape_4_3", "768x1024": "portrait_4_3", "1536x1024": "landscape_3_2", "1024x1536": "portrait_3_2", "576x1024": "portrait_16_9", "1024x576": "landscape_16_9", }; /** * Handle image generation request * @param {object} options * @param {object} options.body - Request body * @param {object} options.credentials - Provider credentials { apiKey, accessToken } * @param {object} options.log - Logger * @param {string} [options.resolvedProvider] - Pre-resolved provider ID (from route layer custom model resolution) */ export async function handleImageGeneration({ body, credentials, log, resolvedProvider = null, signal = null, clientHeaders = null, }) { let provider, model; if (resolvedProvider) { // Provider was already resolved by the route layer (custom model from DB) // Extract model name from the full "provider/model" string provider = resolvedProvider; const modelStr = body.model || ""; model = modelStr.startsWith(provider + "/") ? modelStr.slice(provider.length + 1) : modelStr; } else { // Standard path: resolve from built-in image registry const parsed = parseImageModel(body.model); provider = parsed.provider; model = parsed.model; } if (!provider) { return { success: false, status: 400, error: `Invalid image model: ${body.model}. Use format: provider/model`, }; } const providerConfig = getImageProvider(provider); // For custom models without a built-in provider config, use OpenAI-compatible handler // with a synthetic config based on the provider's credentials if (!providerConfig) { if (!resolvedProvider) { return { success: false, status: 400, error: `Unknown image provider: ${provider}`, }; } // Custom model: use OpenAI-compatible format with provider's base URL // The credentials were already resolved by the route layer if (log) { log.info("IMAGE", `Custom model ${provider}/${model} — using OpenAI-compatible handler`); } const syntheticConfig = { id: provider, // #3205: custom OpenAI-compatible nodes store their base URL in // credentials.providerSpecificData.baseUrl (same as the chat path — // see executors/default.ts:buildUrl / services/provider.ts:buildProviderUrl). // Previously only the (always-absent) top-level credentials.baseUrl was // read, so every custom image node fell back to the Gemini endpoint and // returned "Please pass a valid API key". baseUrl: resolveImageBaseUrl( credentials, `https://generativelanguage.googleapis.com/v1beta/openai/images/generations` ), authType: "apikey", authHeader: "bearer", format: "openai", }; return handleOpenAIImageGeneration({ model, provider, providerConfig: syntheticConfig, body, credentials, log, }); } if (providerConfig.format === "gemini-image") { return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }); } if (providerConfig.format === "imagen3") { return handleImagen3ImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "hyperbolic") { return handleHyperbolicImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "huggingface-image") { return handleHuggingFaceImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "fal-ai") { return handleFalAIImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "stability-ai") { return handleStabilityAIImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "black-forest-labs") { return handleBlackForestLabsImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "recraft") { return handleRecraftImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "topaz") { return handleTopazImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "chatgpt-web") { return handleChatGptWebImageGeneration({ model, provider, body, credentials, log, signal, clientHeaders, }); } if (providerConfig.format === "nanobanana") { return handleNanoBananaImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "kie-image") { return handleKieImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "sdwebui") { return handleSDWebUIImageGeneration({ model, provider, providerConfig, body, log }); } if (providerConfig.format === "comfyui") { return handleComfyUIImageGeneration({ model, provider, providerConfig, body, log }); } if (providerConfig.format === "codex-responses") { return handleCodexImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "haiper-image") { return handleHaiperImageGeneration({ model, provider, providerConfig, body, credentials, log }); } if (providerConfig.format === "leonardo-image") { return handleLeonardoImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "ideogram-image") { return handleIdeogramImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } if (providerConfig.format === "nvidia-nim") { return handleNvidiaNimImageGeneration({ model, provider, providerConfig, body, credentials, log, }); } return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log }); } function normalizeKieImageResult(recordData: unknown): string[] { const record = isJsonObject(recordData) ? recordData : {}; const data = isJsonObject(record.data) ? record.data : {}; const response = isJsonObject(data.response) ? data.response : {}; const resultJson = parseKieResultJson(recordData); const urls = new Set(); const add = (val: unknown) => { if (typeof val === "string" && val.startsWith("http")) urls.add(val); if (Array.isArray(val)) { val.forEach((v) => { if (typeof v === "string" && v.startsWith("http")) urls.add(v); }); } }; // Check resultJson (common in Market API) add(resultJson?.resultUrls); add(resultJson?.imageUrls); add(resultJson?.resultUrl); add(resultJson?.imageUrl); // Check data.response (common in 4o-image API) add(response.resultUrls); add(response.resultUrl); // Check direct data fields add(data.resultImageUrls); add(data.resultImageUrl); add(data.url); return Array.from(urls); } async function handleKieImageGeneration({ model, provider, providerConfig, body, credentials, log, }: KieImageOptions) { const startTime = Date.now(); const token = credentials?.apiKey || credentials?.accessToken; const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300000); const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, 2500); const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); const size = typeof body.size === "string" ? body.size : undefined; if (!token) { return saveImageErrorResult({ provider, model, status: 401, startTime, error: "KIE API key is required", }); } // Check if model is a Market model (unified API) const fullRegistry = getImageProvider(provider); const modelEntry = fullRegistry?.models?.find((m) => m.id === model); const isMarket = modelEntry?.isMarket || model.includes("/"); const { imageUrl } = extractImageInputs(body); let baseUrl = ""; let payload: Record = {}; if (isMarket) { // Unified Market API endpoint baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/createTask`; const input: Record = { prompt, aspect_ratio: mapImageSize(size, "1:1"), }; if (imageUrl) { input.image_url = imageUrl; } payload = { model, input, }; } else { // Legacy/Direct endpoint const modelPath = model.replace("-t2i", "").replace("-i2i", ""); baseUrl = providerConfig.baseUrl.includes(model) ? providerConfig.baseUrl : `https://api.kie.ai/api/v1/${modelPath}/generate`; payload = { prompt, size: mapImageSize(size, "1:1"), nVariants: body.n || 1, }; } if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); log.info( "IMAGE", `${provider}/${model} (${isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` ); } try { const endpoint = isMarket ? "/api/v1/jobs/createTask" : new URL(baseUrl).pathname; const createBaseUrl = isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, ""); const createData = await kieExecutor.createTask({ baseUrl: createBaseUrl, token, payload, endpoint, }); const taskId = createData?.data?.taskId || createData?.taskId; if (!taskId) { const errorMessage = createData?.msg || createData?.message || createData?.error || "KIE image generation did not return taskId"; if (log) { log.error("IMAGE", `KIE createTask failed: ${JSON.stringify(createData)}`); } return saveImageErrorResult({ provider, model, status: 502, startTime, error: errorMessage, requestBody: payload, }); } // Use statusUrl from providerConfig if available, fallback to dynamic derivation const statusUrl = isMarket ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo` : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") ? providerConfig.statusUrl : baseUrl.replace(/\/generate$/, "/record-info"); const { data: recordData, state } = await kieExecutor.pollTask({ statusUrl, taskId: String(taskId), token, timeoutMs, pollIntervalMs, }); if (state === "success") { if (log) { log.info("IMAGE", `KIE poll success for task ${taskId}`); } const urls = normalizeKieImageResult(recordData); const images = urls.map((url: string) => ({ url, revised_prompt: prompt })); return saveImageSuccessResult({ provider, model, startTime, requestBody: payload, responseBody: { images_count: images.length }, images, }); } const record = isJsonObject(recordData) ? recordData : {}; const recordDataBody = isJsonObject(record.data) ? record.data : {}; const errorMessage = recordDataBody.errorMessage || recordDataBody.failMsg || record.msg || "KIE image task failed"; if (log) { log.error("IMAGE", `KIE poll failed for task ${taskId}: ${JSON.stringify(recordData)}`); } return saveImageErrorResult({ provider, model, status: 502, startTime, error: String(errorMessage), requestBody: payload, }); } catch (err: unknown) { return saveImageErrorResult({ provider, model, status: getKieErrorStatus(err, 502), startTime, error: `Image provider error: ${getKieErrorMessage(err, "KIE image generation failed")}`, }); } } /** * Handle Gemini-format image generation (Antigravity / Nano Banana) * Uses Gemini's generateContent API with responseModalities: ["TEXT", "IMAGE"] */ async function handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }) { const startTime = Date.now(); const url = providerConfig.baseUrl; const provider = "antigravity"; const credentialRecord = credentials || {}; const token = credentialRecord.accessToken || credentialRecord.apiKey; const providerSpecificData = credentialRecord.providerSpecificData; const providerSpecificProjectId = providerSpecificData && typeof providerSpecificData === "object" ? (providerSpecificData as Record).projectId : null; const credentialProjectId = typeof credentialRecord.projectId === "string" ? credentialRecord.projectId.trim() : ""; const providerProjectId = typeof providerSpecificProjectId === "string" ? providerSpecificProjectId.trim() : ""; const projectId = credentialProjectId || providerProjectId || null; const candidateCount = typeof body.n === "number" && Number.isFinite(body.n) && body.n > 0 ? Math.floor(body.n) : 1; const promptText = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); // Summarized request for call log const logRequestBody = { model: body.model, prompt: promptText.slice(0, 200), size: body.size || "default", n: candidateCount, }; if (!projectId || typeof projectId !== "string") { return saveImageErrorResult({ provider, model, status: 400, startTime, error: "Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.", requestBody: logRequestBody, }); } const antigravityBody = { project: projectId, requestId: `image_gen/${Date.now()}/${randomUUID()}/0`, request: { contents: [ { role: "user", parts: [{ text: promptText }], }, ], generationConfig: { candidateCount, imageConfig: { aspectRatio: normalizeImageAspectRatio(body.aspect_ratio, body.size), }, }, }, model, userAgent: getAntigravityEnvelopeUserAgent(credentialRecord), requestType: "image_gen", }; const headers = { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }; applyAntigravityClientProfileHeaders(headers, credentialRecord, antigravityBody); delete headers["x-goog-user-project"]; if (log) { const promptPreview = promptText.slice(0, 60); log.info( "IMAGE", `antigravity/${model} (gemini) | prompt: "${promptPreview}..." | format: gemini-image` ); } try { const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(antigravityBody), }); if (!response.ok) { const errorText = await response.text(); const safeError = sanitizeImageProviderError(errorText); const safeErrorLog = typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); if (log) { log.error("IMAGE", `antigravity error ${response.status}: ${safeErrorLog.slice(0, 200)}`); } saveCallLog({ method: "POST", path: "/v1/images/generations", status: response.status, model: `antigravity/${model}`, provider, duration: Date.now() - startTime, error: safeErrorLog.slice(0, 500), requestBody: logRequestBody, }).catch(() => {}); return { success: false, status: response.status, error: safeError }; } const data = await response.json(); const responseBody = data.response || data; // Extract image data from Antigravity's wrapped Gemini response. const images = []; const candidates = responseBody.candidates || []; for (const candidate of candidates) { const parts = candidate.content?.parts || []; for (const part of parts) { if (part.inlineData) { images.push({ b64_json: part.inlineData.data, revised_prompt: parts.find((p) => p.text)?.text || promptText, }); } } } saveCallLog({ method: "POST", path: "/v1/images/generations", status: 200, model: `antigravity/${model}`, provider, duration: Date.now() - startTime, tokens: { prompt_tokens: 0, completion_tokens: 0 }, requestBody: logRequestBody, responseBody: { images_count: images.length }, }).catch(() => {}); return { success: true, data: { created: Math.floor(Date.now() / 1000), data: images, }, }; } catch (err) { if (log) { log.error("IMAGE", `antigravity fetch error: ${err.message}`); } saveCallLog({ method: "POST", path: "/v1/images/generations", status: 502, model: `antigravity/${model}`, provider, duration: Date.now() - startTime, error: err.message, requestBody: logRequestBody, }).catch(() => {}); return { success: false, status: 502, error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, }; } } /** * Handle OpenAI-compatible image generation (standard providers + Nebius fallback) */ async function handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log, }) { const startTime = Date.now(); // Summarized request for call log const logRequestBody = { model: body.model, prompt: typeof body.prompt === "string" ? body.prompt.slice(0, 200) : String(body.prompt ?? "").slice(0, 200), size: body.size || "default", n: body.n || 1, quality: body.quality || undefined, }; // Build upstream request (OpenAI-compatible format) const upstreamBody: Record = { model: model, prompt: body.prompt, }; // Pass optional parameters if (body.n !== undefined) upstreamBody.n = body.n; if (body.size !== undefined) upstreamBody.size = body.size; if (body.quality !== undefined) upstreamBody.quality = body.quality; if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; if (body.style !== undefined) upstreamBody.style = body.style; const { imageUrl } = extractImageInputs(body); if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(model)) { upstreamBody.image_url = imageUrl; } // Build headers const headers = { "Content-Type": "application/json", }; const token = credentials.apiKey || credentials.accessToken; if (providerConfig.authHeader === "bearer") { headers["Authorization"] = `Bearer ${token}`; } else if (providerConfig.authHeader === "x-api-key") { headers["x-api-key"] = token; } if (log) { const promptPreview = typeof body.prompt === "string" ? body.prompt.slice(0, 60) : String(body.prompt ?? "").slice(0, 60); log.info( "IMAGE", `${provider}/${model} | prompt: "${promptPreview}..." | size: ${body.size || "default"}` ); } const requestBody = JSON.stringify(upstreamBody); // Try primary URL let result = await fetchImageEndpoint( providerConfig.baseUrl, headers, requestBody, provider, log ); // Fallback for providers with fallbackUrl (e.g., Nebius) if ( !result.success && providerConfig.fallbackUrl && [404, 410, 502, 503].includes(result.status) ) { if (log) { log.info("IMAGE", `${provider}: primary URL failed (${result.status}), trying fallback...`); } result = await fetchImageEndpoint( providerConfig.fallbackUrl, headers, requestBody, provider, log ); } // Save call log after result is determined saveCallLog({ method: "POST", path: "/v1/images/generations", status: result.status || (result.success ? 200 : 502), model: `${provider}/${model}`, provider, duration: Date.now() - startTime, tokens: { prompt_tokens: 0, completion_tokens: 0 }, error: result.success ? null : typeof result.error === "string" ? result.error.slice(0, 500) : null, requestBody: logRequestBody, responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, }).catch(() => {}); return result; } /** * OpenAI-compatible image *edit* forwarder for custom providers (#3214 / #3215). * * Mirrors `handleOpenAIImageGeneration` but posts multipart/form-data to the node's * `/images/edits` endpoint and returns the upstream OpenAI-compatible response. Kept * separate from the chatgpt-web edit flow, which continues a saved conversation node * rather than forwarding a stateless edit. The fetch helper leaves Content-Type unset so * `fetch` derives the multipart boundary from the FormData body. */ export async function handleOpenAIImageEdit({ model, provider, credentials, prompt, imageBytes, imageMime, size, responseFormat, n = 1, log, }: { model: string; provider: string; credentials: | { apiKey?: string; accessToken?: string; baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null; } | null | undefined; prompt: string; imageBytes: Buffer; imageMime?: string | null; size?: string | null; responseFormat?: string | null; n?: number; log?: { info: (tag: string, message: string) => void } | null; }) { const startTime = Date.now(); const url = resolveImageBaseUrl( credentials, `https://generativelanguage.googleapis.com/v1beta/openai/images/edits`, "edits" ); // Build the multipart body as a Buffer with an explicit boundary instead of a global // `FormData`. In production `globalThis.fetch` is patched with node_modules/undici's fetch, // whose `FormData` class differs from `globalThis.FormData` — passing a native FormData // makes undici serialize it as the string "[object FormData]" (text/plain), dropping every // field (including `model`, which reaches the upstream empty). A Buffer body is accepted // verbatim by any fetch implementation. (#3273) const boundary = `----OmniRouteImageEdit${randomUUID().replace(/-/g, "")}`; const CRLF = "\r\n"; const partBuffers: Buffer[] = []; const appendField = (name: string, value: string) => { partBuffers.push( Buffer.from( `--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}` ) ); }; appendField("model", model); appendField("prompt", prompt); if (size) appendField("size", size); if (responseFormat) appendField("response_format", responseFormat); appendField("n", String(n || 1)); partBuffers.push( Buffer.from( `--${boundary}${CRLF}Content-Disposition: form-data; name="image"; filename="image.png"${CRLF}` + `Content-Type: ${imageMime || "image/png"}${CRLF}${CRLF}` ) ); partBuffers.push(imageBytes); partBuffers.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`)); const multipartBody = Buffer.concat(partBuffers); const headers: Record = { "Content-Type": `multipart/form-data; boundary=${boundary}`, }; const token = credentials?.apiKey || credentials?.accessToken; if (token) headers["Authorization"] = `Bearer ${token}`; if (log) { log.info("IMAGE", `${provider}/${model} (edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}`); } const result = await fetchImageEndpoint( url, headers, multipartBody as unknown as BodyInit, provider, log ); saveCallLog({ method: "POST", path: "/v1/images/edits", status: result.status || (result.success ? 200 : 502), model: `${provider}/${model}`, provider, duration: Date.now() - startTime, tokens: { prompt_tokens: 0, completion_tokens: 0 }, error: result.success ? null : typeof result.error === "string" ? result.error.slice(0, 500) : null, requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, }).catch(() => {}); return result; } export async function handleImageEdit({ provider, model, body, imageBytes, credentials, log, signal = null, clientHeaders = null, }: { provider: string; model: string; body: Record; imageBytes: Buffer; imageMime?: string; // accepted for symmetry with route layer; not used credentials: any; log: any; signal?: AbortSignal | null; clientHeaders?: Record | null; }) { const startTime = Date.now(); const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; if (!prompt) { return saveImageErrorResult({ provider, model, status: 400, startTime, error: "Prompt is required for image edit", }); } if (!credentials?.apiKey) { return saveImageErrorResult({ provider, model, status: 401, startTime, error: "ChatGPT Web credentials missing session cookie", }); } const imageHash = createHash("sha256").update(imageBytes).digest("hex"); const cached = findChatGptImageBySha256(imageHash); const wantsBase64 = body.response_format === "b64_json"; const requestBody = { model, prompt: prompt.slice(0, 500), size: body.size || undefined, image_hash: imageHash.slice(0, 16), image_bytes: imageBytes.length, cached_match: Boolean(cached?.entry.context), }; if (!cached?.entry.context) { // chatgpt-web's image_gen tool can only edit an image when we continue // the original conversation node. If we never generated this image (or // its 30-minute TTL elapsed), there's no node to continue. Return a // clear, actionable error — much better than silently spawning an // unrelated image and confusing the user. log?.warn?.( "IMAGE", `chatgpt-web edit: no cached match for sha256=${imageHash.slice(0, 16)} (bytes=${imageBytes.length}); returning 400` ); return saveImageErrorResult({ provider, model, status: 400, startTime, error: "chatgpt-web image edit only works for images recently generated through this OmniRoute instance " + "(cache window: 30 minutes). Re-generate the image and try the edit immediately, or disable image-edit " + "in your client to use plain chat-completion edit prompts instead.", requestBody, }); } // Build a synthetic chat thread that surfaces the cached image URL on // the assistant turn. The executor's parseOpenAIMessages picks up the // URL, findCachedImageContext resolves it to {conversationId, // parentMessageId}, and looksLikeImageEditRequest fires on the user // prompt — together producing a continuation request that actually // edits the saved image. // // The synthetic user prompt is anchored with both an edit verb AND an // image-gen verb so the executor's heuristics fire regardless of what // wording the caller used ("now make it brighter", "tweak this", ...): // - looksLikeImageEditRequest: matches "edit" + "image" within 120 chars // - looksLikeImageGenRequest: matches "generate" + "image" within 40 chars // Either match alone would set forImageGen, but covering both is cheap // insurance for prompts that don't fit common phrasings. const messages: Array<{ role: string; content: string }> = [ { role: "assistant", // The base URL is irrelevant — only the path is parsed by // CACHED_IMAGE_URL_RE in the executor's findCachedImageContext. content: `![image](http://internal/v1/chatgpt-web/image/${cached.id})`, }, { role: "user", content: `Edit the image and generate the new image: ${prompt}`, }, ]; const executor = new ChatGptWebExecutor(); const result = await executor.execute({ model, body: { messages }, stream: false, credentials, signal, log, clientHeaders, }); const responseText = await result.response.text(); if (result.response.status >= 400) { return saveImageErrorResult({ provider, model, status: result.response.status, startTime, error: responseText, requestBody, }); } let content = ""; try { const json = JSON.parse(responseText); content = String(json?.choices?.[0]?.message?.content || ""); } catch { content = responseText; } const urls = extractMarkdownImageUrls(content); if (urls.length === 0) { return saveImageErrorResult({ provider, model, status: 502, startTime, error: `ChatGPT Web edit completed without returning image markdown: ${content.slice(0, 300)}`, requestBody, }); } const images: Array<{ url?: string; b64_json?: string }> = []; for (const url of urls) { if (!wantsBase64) { images.push({ url }); continue; } const id = url.match(CHATGPT_WEB_IMAGE_ID_RE)?.[1]; const cachedNew = id ? getChatGptImage(id) : null; if (!cachedNew) { return saveImageErrorResult({ provider, model, status: 502, startTime, error: "ChatGPT Web image bytes expired before b64_json conversion", requestBody, }); } images.push({ b64_json: cachedNew.bytes.toString("base64") }); } return saveImageSuccessResult({ provider, model, startTime, requestBody, responseBody: { images_count: images.length, edit_match: Boolean(cached?.entry.context) }, images, }); } async function handleFalAIImageGeneration({ model, provider, providerConfig, body, credentials, log, }) { const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; const { imageUrl, imageUrls } = extractImageInputs(body); const upstreamBody: Record = { prompt: body.prompt, sync_mode: body.sync_mode ?? true, }; if (body.n !== undefined) upstreamBody.num_images = Number(body.n) || 1; if (body.negative_prompt) upstreamBody.negative_prompt = body.negative_prompt; if (body.seed !== undefined) upstreamBody.seed = body.seed; if (body.style) upstreamBody.style = normalizeRecraftStyle(body.style); const outputFormat = normalizeRequestedImageFormat(body, "png"); if (outputFormat) upstreamBody.output_format = outputFormat; if (model.includes("flux-pro/v1.1") && !model.includes("ultra")) { upstreamBody.image_size = mapFalImageSize(body.size, "landscape_4_3"); } else if ( model.includes("bytedance/") || model.includes("stable-diffusion") || model.includes("ideogram") || model.includes("recraft/v3") ) { upstreamBody.image_size = mapFalImageSize(body.size, "square_hd"); } else { upstreamBody.aspect_ratio = body.aspect_ratio || mapFalAspectRatio(body.size, "1:1"); } if (body.quality === "hd" && model.includes("ultra")) { upstreamBody.raw = true; } if (imageUrl && model.includes("flux-pro/v1.1-ultra")) { upstreamBody.image_url = imageUrl; } if (imageUrls.length > 0 && model.includes("ideogram")) { upstreamBody.image_urls = imageUrls; } if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); log.info("IMAGE", `${provider}/${model} (fal-ai) | prompt: "${promptPreview}..."`); } try { const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${model}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Key ${token}`, }, body: JSON.stringify(upstreamBody), }); if (!response.ok) { const errorText = await response.text(); if (log) log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); return saveImageErrorResult({ provider, model, status: response.status, startTime, error: errorText, requestBody: upstreamBody, }); } const payload = await response.json(); const images = await normalizeProviderImagePayload(payload, body, log); return saveImageSuccessResult({ provider, model, startTime, requestBody: upstreamBody, responseBody: { images_count: images.length }, created: payload.created, images, }); } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); return saveImageErrorResult({ provider, model, status: 502, startTime, error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, }); } } async function handleStabilityAIImageGeneration({ model, provider, providerConfig, body, credentials, log, }) { const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; const endpoint = STABILITY_GENERATION_ENDPOINTS[model] || STABILITY_EDIT_ENDPOINTS[model]; if (!endpoint) { return { success: false, status: 400, error: `Unsupported Stability AI image model: ${model}`, }; } const { imageUrl, maskUrl } = extractImageInputs(body); const upstreamBody: Record = { output_format: model === "remove-background" ? normalizeRequestedImageFormat(body, "png", ["png", "webp"]) : normalizeRequestedImageFormat(body, "png"), }; const formData = new FormData(); appendOptionalFormValue(formData, "output_format", upstreamBody.output_format); if (body.prompt) { upstreamBody.prompt = body.prompt; appendOptionalFormValue(formData, "prompt", body.prompt); } if (body.negative_prompt) { upstreamBody.negative_prompt = body.negative_prompt; appendOptionalFormValue(formData, "negative_prompt", body.negative_prompt); } if (body.seed !== undefined) { upstreamBody.seed = body.seed; appendOptionalFormValue(formData, "seed", body.seed); } try { if (STABILITY_GENERATION_ENDPOINTS[model]) { if (model.startsWith("sd3.5")) { upstreamBody.model = model; appendOptionalFormValue(formData, "model", model); } if (imageUrl) { const imageSource = await resolveImageSource(imageUrl); upstreamBody.mode = "image-to-image"; appendOptionalFormValue(formData, "mode", "image-to-image"); upstreamBody.image = imageSource.base64; appendImageFormValue(formData, "image", imageSource, "image"); if (body.strength !== undefined) { upstreamBody.strength = body.strength; appendOptionalFormValue(formData, "strength", body.strength); } } else { upstreamBody.mode = "text-to-image"; appendOptionalFormValue(formData, "mode", "text-to-image"); } if (!model.startsWith("sd3.5") || !imageUrl) { const aspectRatio = body.aspect_ratio || mapImageSize(body.size); upstreamBody.aspect_ratio = aspectRatio; appendOptionalFormValue(formData, "aspect_ratio", aspectRatio); } if (body.style_preset) { upstreamBody.style_preset = body.style_preset; appendOptionalFormValue(formData, "style_preset", body.style_preset); } } else { if (imageUrl) { const imageSource = await resolveImageSource(imageUrl); upstreamBody.image = imageSource.base64; appendImageFormValue(formData, "image", imageSource, "image"); } if (maskUrl && shouldIncludeStabilityMask(model)) { const maskSource = await resolveImageSource(maskUrl); upstreamBody.mask = maskSource.base64; appendImageFormValue(formData, "mask", maskSource, "mask"); } if (body.search_prompt) { upstreamBody.search_prompt = body.search_prompt; appendOptionalFormValue(formData, "search_prompt", body.search_prompt); } if (body.grow_mask !== undefined) { upstreamBody.grow_mask = body.grow_mask; appendOptionalFormValue(formData, "grow_mask", body.grow_mask); } if (body.control_strength !== undefined) { upstreamBody.control_strength = body.control_strength; appendOptionalFormValue(formData, "control_strength", body.control_strength); } if (body.creativity !== undefined) { upstreamBody.creativity = body.creativity; appendOptionalFormValue(formData, "creativity", body.creativity); } if (body.left !== undefined) { upstreamBody.left = body.left; appendOptionalFormValue(formData, "left", body.left); } if (body.right !== undefined) { upstreamBody.right = body.right; appendOptionalFormValue(formData, "right", body.right); } if (body.up !== undefined) { upstreamBody.up = body.up; appendOptionalFormValue(formData, "up", body.up); } if (body.down !== undefined) { upstreamBody.down = body.down; appendOptionalFormValue(formData, "down", body.down); } if (body.style_preset) { upstreamBody.style_preset = body.style_preset; appendOptionalFormValue(formData, "style_preset", body.style_preset); } if (STABILITY_CONTROL_MODELS.has(model) && !upstreamBody.prompt) { upstreamBody.prompt = body.prompt || ""; appendOptionalFormValue(formData, "prompt", body.prompt || ""); } } if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); log.info("IMAGE", `${provider}/${model} (stability-ai) | prompt: "${promptPreview}..."`); } const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, { method: "POST", headers: { Accept: "application/json", Authorization: `Bearer ${token}`, }, body: formData, }); if (!response.ok) { const errorText = await response.text(); if (log) log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); return saveImageErrorResult({ provider, model, status: response.status, startTime, error: errorText, requestBody: upstreamBody, }); } const contentType = response.headers.get("content-type") || ""; let payload; if (contentType.includes("application/json")) { payload = await response.json(); } else { const buffer = Buffer.from(await response.arrayBuffer()); payload = { image: buffer.toString("base64") }; } const images = await normalizeProviderImagePayload(payload, body, log); return saveImageSuccessResult({ provider, model, startTime, requestBody: upstreamBody, responseBody: { images_count: images.length }, created: payload.created, images, }); } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); return saveImageErrorResult({ provider, model, status: 502, startTime, error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, }); } } async function handleBlackForestLabsImageGeneration({ model, provider, providerConfig, body, credentials, log, }) { const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; const endpoint = BFL_MODEL_ENDPOINTS[model]; if (!endpoint) { return { success: false, status: 400, error: `Unsupported Black Forest Labs image model: ${model}`, }; } const { imageUrl, maskUrl } = extractImageInputs(body); const upstreamBody: Record = { prompt: body.prompt, output_format: normalizeRequestedImageFormat(body, "png"), }; try { if (BFL_EDIT_MODELS.has(model) && imageUrl) { upstreamBody.input_image = (await resolveImageSource(imageUrl)).base64; } else if (imageUrl && isHttpUrl(imageUrl)) { upstreamBody.image_url = imageUrl; } if (maskUrl && (model === "flux-pro-1.0-fill" || model === "flux-kontext-pro")) { upstreamBody.mask = (await resolveImageSource(maskUrl)).base64; } if (model === "flux-kontext-pro" || model === "flux-kontext-max") { upstreamBody.aspect_ratio = body.aspect_ratio || mapImageSize(body.size); } else if (typeof body.size === "string" && body.size.includes("x")) { const { width, height } = parseSizeToDimensions(body.size, 1024); upstreamBody.width = width; upstreamBody.height = height; } if (body.seed !== undefined) upstreamBody.seed = body.seed; if (body.n !== undefined && model.includes("ultra")) upstreamBody.num_images = Number(body.n) || 1; if (body.quality === "hd" && model.includes("ultra")) upstreamBody.raw = true; if (body.left !== undefined) upstreamBody.left = body.left; if (body.right !== undefined) upstreamBody.right = body.right; if (body.top !== undefined) upstreamBody.top = body.top; if (body.bottom !== undefined) upstreamBody.bottom = body.bottom; if (body.steps !== undefined) upstreamBody.steps = body.steps; if (body.guidance !== undefined) upstreamBody.guidance = body.guidance; if (body.grow_mask !== undefined) upstreamBody.grow_mask = body.grow_mask; if (body.safety_tolerance !== undefined) upstreamBody.safety_tolerance = body.safety_tolerance; if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); log.info("IMAGE", `${provider}/${model} (black-forest-labs) | prompt: "${promptPreview}..."`); } const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", "x-key": token, }, body: JSON.stringify(upstreamBody), }); if (!response.ok) { const errorText = await response.text(); if (log) log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); return saveImageErrorResult({ provider, model, status: response.status, startTime, error: errorText, requestBody: upstreamBody, }); } const initialPayload = await response.json(); const finalPayload = initialPayload.polling_url ? await pollBlackForestLabsResult({ pollingUrl: initialPayload.polling_url, token, body, log, }) : initialPayload; const images = await normalizeProviderImagePayload(finalPayload, body, log); return saveImageSuccessResult({ provider, model, startTime, requestBody: upstreamBody, responseBody: { images_count: images.length }, created: finalPayload.created, images, }); } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); return saveImageErrorResult({ provider, model, status: 502, startTime, error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, }); } } async function handleRecraftImageGeneration({ model, provider, providerConfig, body, credentials, log, }) { const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; const upstreamBody: Record = { model, prompt: body.prompt, }; if (body.n !== undefined) upstreamBody.n = body.n; if (body.size !== undefined) upstreamBody.size = body.size; if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; if (body.style !== undefined) upstreamBody.style = body.style; if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); log.info("IMAGE", `${provider}/${model} (recraft) | prompt: "${promptPreview}..."`); } try { const response = await fetch( `${providerConfig.baseUrl.replace(/\/$/, "")}/v1/images/generations`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(upstreamBody), } ); if (!response.ok) { const errorText = await response.text(); if (log) log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); return saveImageErrorResult({ provider, model, status: response.status, startTime, error: errorText, requestBody: upstreamBody, }); } const payload = await response.json(); const images = await normalizeProviderImagePayload(payload, body, log); return saveImageSuccessResult({ provider, model, startTime, requestBody: upstreamBody, responseBody: { images_count: images.length }, created: payload.created, images, }); } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); return saveImageErrorResult({ provider, model, status: 502, startTime, error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, }); } } async function handleTopazImageGeneration({ model, provider, providerConfig, body, credentials, log, }) { const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; const { imageUrl } = extractImageInputs(body); if (!imageUrl) { return { success: false, status: 400, error: `Topaz model ${model} requires an input image`, }; } try { const imageSource = await resolveImageSource(imageUrl); const formData = new FormData(); const blob = new Blob([imageSource.buffer], { type: imageSource.contentType || "image/png" }); formData.append("image", blob, "image.png"); if (typeof body.size === "string" && body.size.includes("x")) { const { width, height } = parseSizeToDimensions(body.size, 1024); formData.append("output_width", String(width)); formData.append("output_height", String(height)); } if (log) { const promptPreview = String(body.prompt ?? "enhance image").slice(0, 60); log.info("IMAGE", `${provider}/${model} (topaz) | prompt: "${promptPreview}..."`); } const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/image/v1/enhance`, { method: "POST", headers: { Accept: "image/jpeg", "X-API-Key": token, }, body: formData, }); if (!response.ok) { const errorText = await response.text(); if (log) log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); return saveImageErrorResult({ provider, model, status: response.status, startTime, error: errorText, }); } const contentType = response.headers.get("content-type") || "image/jpeg"; const buffer = Buffer.from(await response.arrayBuffer()); const base64 = buffer.toString("base64"); const wantsBase64 = body.response_format === "b64_json"; const images = [ wantsBase64 ? { b64_json: base64, revised_prompt: body.prompt } : { url: `data:${contentType};base64,${base64}`, revised_prompt: body.prompt }, ]; return saveImageSuccessResult({ provider, model, startTime, responseBody: { images_count: images.length }, images, }); } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); return saveImageErrorResult({ provider, model, status: 502, startTime, error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, }); } } async function pollBlackForestLabsResult({ pollingUrl, token, body, log }) { const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300000); const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, 1500); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const response = await fetch(pollingUrl, { method: "GET", headers: { "x-key": token, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`BFL polling failed (${response.status}): ${errorText}`); } const payload = await response.json(); const status = payload?.status; if (status === "Ready") { return payload; } if (BFL_FAILURE_STATUSES.has(status)) { throw new Error(`BFL image generation failed: ${status}`); } if (log) { log.info("IMAGE", `black-forest-labs polling status: ${String(status || "Pending")}`); } await sleep(pollIntervalMs); } throw new Error(`BFL polling timed out after ${timeoutMs}ms`); } function extractImageInputs(body) { const imageUrls = []; const seen = new Set(); const pushCandidate = (candidate) => { if (typeof candidate !== "string") return; const trimmed = candidate.trim(); if (!trimmed || seen.has(trimmed)) return; seen.add(trimmed); imageUrls.push(trimmed); }; pushCandidate(body?.image_url); pushCandidate(body?.image); if (Array.isArray(body?.imageUrls)) { for (const candidate of body.imageUrls) pushCandidate(candidate); } if (Array.isArray(body?.image_urls)) { for (const candidate of body.image_urls) pushCandidate(candidate); } if (Array.isArray(body?.messages)) { for (const msg of body.messages) { if (!Array.isArray(msg?.content)) continue; for (const part of msg.content) { if (part?.type === "image_url") { pushCandidate(part?.image_url?.url); } } } } return { imageUrl: imageUrls[0] || null, imageUrls, maskUrl: typeof body?.mask_url === "string" ? body.mask_url : typeof body?.mask === "string" ? body.mask : null, }; } async function resolveImageSource(source) { if (typeof source !== "string" || source.trim().length === 0) { throw new Error("Invalid image source"); } const trimmed = source.trim(); const dataUriMatch = /^data:([^;]+);base64,(.+)$/i.exec(trimmed); if (dataUriMatch) { const [, contentType, base64] = dataUriMatch; return { buffer: Buffer.from(base64, "base64"), base64, contentType, }; } if (isHttpUrl(trimmed)) { const remoteImage = await fetchRemoteImage(trimmed); return { buffer: remoteImage.buffer, base64: remoteImage.buffer.toString("base64"), contentType: remoteImage.contentType, }; } return { buffer: Buffer.from(trimmed, "base64"), base64: trimmed, contentType: "application/octet-stream", }; } function parseSizeToDimensions(size, fallback = 1024) { if (typeof size !== "string" || !size.includes("x")) { return { width: fallback, height: fallback }; } const [widthRaw, heightRaw] = size.split("x"); const width = Number(widthRaw); const height = Number(heightRaw); return { width: Number.isFinite(width) && width > 0 ? width : fallback, height: Number.isFinite(height) && height > 0 ? height : fallback, }; } function normalizeRequestedImageFormat( body, fallback = "png", allowedFormats = ["jpeg", "png", "webp"] ) { const formatCandidate = typeof body?.output_format === "string" ? body.output_format.toLowerCase() : typeof body?.response_format === "string" && !["url", "b64_json"].includes(body.response_format.toLowerCase()) ? body.response_format.toLowerCase() : fallback; if (allowedFormats.includes(formatCandidate)) { return formatCandidate; } return fallback; } function mapFalImageSize(size, fallback = "square_hd") { if (typeof size !== "string") return fallback; if (FAL_PRESET_SIZES[size]) return FAL_PRESET_SIZES[size]; if (size.includes("x")) { const { width, height } = parseSizeToDimensions(size, 1024); return { width, height }; } return fallback; } function mapFalAspectRatio(size, fallback = "1:1") { if (!size) return fallback; return mapImageSize(size); } function normalizeRecraftStyle(style) { if (style === "vivid") return "digital_illustration"; if (style === "natural") return "realistic_image"; return style; } function shouldIncludeStabilityMask(model) { return new Set([ "inpaint", "erase", "search-and-replace", "search-and-recolor", "replace-background-and-relight", ]).has(model); } async function normalizeProviderImagePayload(payload, body, log) { const candidates = []; const pushCandidate = (value) => { if (value === undefined || value === null) return; candidates.push(value); }; if (Array.isArray(payload?.data)) { for (const item of payload.data) pushCandidate(item); } if (Array.isArray(payload?.images)) { for (const item of payload.images) pushCandidate(item); } if (payload?.image) pushCandidate({ b64_json: payload.image }); if (payload?.url) pushCandidate({ url: payload.url }); if (payload?.sample) pushCandidate({ url: payload.sample }); if (payload?.result?.sample) pushCandidate({ url: payload.result.sample }); if (Array.isArray(payload?.result?.images)) { for (const item of payload.result.images) pushCandidate(item); } const normalized = []; for (const candidate of candidates) { const item = await normalizeProviderImageCandidate(candidate, body); if (item) normalized.push(item); } if (normalized.length === 0 && log) { log.warn( "IMAGE", `Provider returned no recognizable image payload: ${JSON.stringify(payload).slice(0, 240)}` ); } return normalized; } async function normalizeProviderImageCandidate(candidate, body) { const wantsBase64 = body?.response_format === "b64_json"; let url = null; let b64 = null; if (typeof candidate === "string") { const dataUriMatch = /^data:[^;]+;base64,(.+)$/i.exec(candidate); if (dataUriMatch) { b64 = dataUriMatch[1]; } else if (isHttpUrl(candidate)) { url = candidate; } else { b64 = candidate; } } else if (candidate && typeof candidate === "object") { url = firstString(candidate.url, candidate.image_url, candidate.sample, candidate.file_url) || null; b64 = firstString(candidate.b64_json, candidate.image, candidate.base64, candidate.data) || null; } if (wantsBase64 && !b64 && url) { b64 = (await resolveImageSource(url)).base64; } if (url && !wantsBase64) { return { url, revised_prompt: body?.prompt }; } if (b64) { return { b64_json: b64, revised_prompt: body?.prompt }; } if (url) { return { url, revised_prompt: body?.prompt }; } return null; } function firstString(...values) { for (const value of values) { if (typeof value === "string" && value.length > 0) return value; } return null; } function isHttpUrl(value) { return typeof value === "string" && /^https?:\/\//i.test(value); } /** * Codex image generation — translate GPT-Image-style /v1/images/generations * request into a /v1/responses call with the `image_generation` hosted tool, * parse the SSE stream, and return the base64 PNG in OpenAI image response shape. * * Requires ChatGPT OAuth credentials (Codex provider connection). The hosted * image_generation tool is only served upstream under ChatGPT auth; API-key * users will receive a 400 from OpenAI. */ export function extractImageGenerationCalls( sseText: string ): Array<{ b64: string; revisedPrompt: string | null }> { const results: Array<{ b64: string; revisedPrompt: string | null }> = []; const lines = String(sseText || "").split("\n"); for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; let evt: Record; try { evt = JSON.parse(payload) as Record; } catch { continue; } if (evt?.type !== "response.output_item.done") continue; const item = evt.item as Record | undefined; if (!item || item.type !== "image_generation_call") continue; const result = typeof item.result === "string" ? item.result : ""; if (!result) continue; const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : null; results.push({ b64: result, revisedPrompt }); } return results; } // The image_generation hosted tool accepts { "auto" | "low" | "medium" | "high" } // for `quality`. Legacy image clients often send "standard" / "hd". Map those values // so OpenWebUI's quality dropdown doesn't silently get rejected upstream. function mapLegacyImageQualityToImageTool(value: string): string { const normalized = value.toLowerCase(); if (normalized === "standard") return "medium"; if (normalized === "hd") return "high"; return normalized; } async function handleCodexImageGeneration({ model, provider, providerConfig, body, credentials, log, }) { const startTime = Date.now(); const prompt = typeof body.prompt === "string" ? body.prompt : ""; if (!prompt.trim()) { return saveImageErrorResult({ provider, model, status: 400, startTime, error: "Prompt is required for Codex image generation", }); } const requestedCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; if (log && requestedCount > 1) { log.warn( "IMAGE", `Codex hosted image_generation returns one image per call; requested n=${requestedCount} will fan out in parallel` ); } const token = credentials?.accessToken || credentials?.apiKey; if (!token) { return saveImageErrorResult({ provider, model, status: 401, startTime, error: "Codex credentials missing accessToken — reconnect the Codex provider", }); } const workspaceId = credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" && !Array.isArray(credentials.providerSpecificData) ? (credentials.providerSpecificData as Record).workspaceId : undefined; // Forward size/quality from the GPT-Image-style body into the hosted tool so // OpenWebUI's size/quality selectors actually take effect. Everything else // (model, n, background, moderation, output_compression) is left to the // Codex backend's defaults — today that's `gpt-image-2`. const toolConfig: Record = { type: "image_generation", output_format: "png" }; if (typeof body.size === "string" && body.size.trim()) { toolConfig.size = body.size.trim(); } if (typeof body.quality === "string" && body.quality.trim()) { toolConfig.quality = mapLegacyImageQualityToImageTool(body.quality.trim()); } const upstreamBody: Record = { model, instructions: "You must call the image_generation tool exactly once to fulfill the user's request. Do not add narration.", input: [ { role: "user", content: [{ type: "input_text", text: prompt }], }, ], tools: [toolConfig], stream: true, store: false, }; const headers: Record = { "Content-Type": "application/json", Accept: "text/event-stream", Authorization: `Bearer ${token}`, Version: getCodexClientVersion(), "User-Agent": getCodexUserAgent(), originator: "codex_cli_rs", }; if (typeof workspaceId === "string" && workspaceId) { headers["chatgpt-account-id"] = workspaceId; headers["session_id"] = workspaceId; } if (log) { log.info( "IMAGE", `${provider}/${model} (codex-responses) | prompt: "${prompt.slice(0, 60)}..."` ); } const fetchOneImage = async () => { let response: Response; try { response = await fetch(providerConfig.baseUrl, { method: "POST", headers, body: JSON.stringify(upstreamBody), }); } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${(err as Error).message}`); return { ok: false as const, error: { provider, model, status: 502, startTime, error: `Image provider error: ${(err as Error).message}`, requestBody: upstreamBody, }, }; } if (!response.ok) { const errorText = await response.text(); if (log) log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); return { ok: false as const, error: { provider, model, status: response.status, startTime, error: errorText, requestBody: upstreamBody, }, }; } const rawSSE = await response.text(); const items = extractImageGenerationCalls(rawSSE); if (items.length === 0) { return { ok: false as const, error: { provider, model, status: 502, startTime, error: "Codex completed without producing an image_generation_call — the model may have declined the tool", requestBody: upstreamBody, }, }; } return { ok: true as const, items }; }; const imageResults = await Promise.all( Array.from({ length: requestedCount }, () => fetchOneImage()) ); const collected: Array<{ b64_json: string; revised_prompt?: string }> = []; for (const imageResult of imageResults) { if (!imageResult.ok) return saveImageErrorResult(imageResult.error); for (const item of imageResult.items) { collected.push({ b64_json: item.b64, ...(item.revisedPrompt ? { revised_prompt: item.revisedPrompt } : {}), }); } } const wantsUrl = body.response_format !== "b64_json"; const data = wantsUrl ? collected.map((item) => ({ url: `data:image/png;base64,${item.b64_json}`, ...(item.revised_prompt ? { revised_prompt: item.revised_prompt } : {}), })) : collected; return saveImageSuccessResult({ provider, model, startTime, requestBody: upstreamBody, responseBody: { images_count: data.length }, images: data, }); } export function saveImageSuccessResult({ provider, model, startTime, requestBody = null, responseBody = null, created = null, images, }) { saveCallLog({ method: "POST", path: "/v1/images/generations", status: 200, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, requestBody, responseBody, }).catch(() => {}); return { success: true, data: { created: created || Math.floor(Date.now() / 1000), data: images, }, }; } export function saveImageErrorResult({ provider, model, status, startTime, error, requestBody = null }) { saveCallLog({ method: "POST", path: "/v1/images/generations", status, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, error: typeof error === "string" ? error.slice(0, 500) : String(error).slice(0, 500), requestBody, }).catch(() => {}); return { success: false, status, error, }; } /** * Fetch a single image endpoint and normalize response */ async function fetchImageEndpoint(url, headers, body, provider, log) { try { let response; try { response = await fetchWithTimeout(url, { method: "POST", headers, body, timeoutMs: getConfiguredTimeout(), }); } catch (err: unknown) { const isAbortError = typeof err === "object" && err !== null && "name" in err && (err as { name?: unknown }).name === "AbortError"; if (err instanceof FetchTimeoutError || isAbortError) { const message = err instanceof Error ? err.message : String(err); if (log) { log.error("IMAGE", `${provider} fetch error: ${message}`); } return { success: false, status: 504, error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, }; } throw err; } if (!response.ok) { const errorText = await response.text(); if (log) { log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); } return { success: false, status: response.status, error: errorText, }; } const data = await response.json(); // Normalize response to OpenAI format return { success: true, data: { created: data.created || Math.floor(Date.now() / 1000), data: data.data || [], }, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); if (log) { log.error("IMAGE", `${provider} fetch error: ${message}`); } return { success: false, status: 502, error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, }; } } /** * Handle Hyperbolic image generation * Uses { model_name, prompt, height, width } and returns { images: [{ image: base64 }] } */ async function handleNanoBananaImageGeneration({ model, provider, providerConfig, body, credentials, log, }) { const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; // Route to pro URL for "nanobanana-pro" model const isPro = model === "nanobanana-pro"; const submitUrl = isPro && providerConfig.proUrl ? providerConfig.proUrl : providerConfig.baseUrl; const statusUrl = providerConfig.statusUrl; const aspectRatio = typeof body.aspectRatio === "string" ? body.aspectRatio : typeof body.aspect_ratio === "string" ? body.aspect_ratio : mapImageSize(body.size); let resolution = typeof body.resolution === "string" ? body.resolution : inferResolutionFromSize(body.size) || "1K"; if (body.quality === "hd" && resolution === "1K") { resolution = "2K"; } const upstreamBody = isPro ? { prompt: body.prompt, resolution, aspectRatio, ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), } : { prompt: body.prompt, type: Array.isArray(body.imageUrls) && body.imageUrls.length > 0 ? "IMAGETOIAMGE" : "TEXTTOIAMGE", numImages: Number.isFinite(body.n) ? Math.max(1, Number(body.n)) : 1, image_size: aspectRatio, ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), }; if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); log.info( "IMAGE", `${provider}/${model} (nanobanana ${isPro ? "pro" : "flash"}) | prompt: "${promptPreview}..."` ); } try { const submitResp = await fetch(submitUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(upstreamBody), }); if (!submitResp.ok) { const errorText = await submitResp.text(); if (log) { log.error( "IMAGE", `${provider} submit error ${submitResp.status}: ${errorText.slice(0, 200)}` ); } saveCallLog({ method: "POST", path: "/v1/images/generations", status: submitResp.status, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, error: errorText.slice(0, 500), }).catch(() => {}); return { success: false, status: submitResp.status, error: errorText }; } const submitData = await submitResp.json(); // Backward compatibility: handle providers returning image payload synchronously const hasSyncPayload = Boolean(submitData?.image) || Array.isArray(submitData?.images) || Array.isArray(submitData?.data) || Boolean(submitData?.data?.[0]?.url) || Boolean(submitData?.data?.[0]?.b64_json); if (hasSyncPayload) { const syncResult = normalizeNanoBananaSyncPayload(submitData, body.prompt); saveCallLog({ method: "POST", path: "/v1/images/generations", status: 200, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, responseBody: { images_count: syncResult.data?.length || 0, mode: "sync" }, }).catch(() => {}); return { success: true, data: { created: Math.floor(Date.now() / 1000), data: syncResult.data }, }; } const taskId = submitData?.data?.taskId || submitData?.taskId; if (!taskId) { const errorText = `NanoBanana submit did not return taskId: ${JSON.stringify(submitData).slice(0, 400)}`; saveCallLog({ method: "POST", path: "/v1/images/generations", status: 502, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, error: errorText, }).catch(() => {}); return { success: false, status: 502, error: errorText }; } if (!statusUrl) { const errorText = "NanoBanana statusUrl is not configured"; saveCallLog({ method: "POST", path: "/v1/images/generations", status: 500, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, error: errorText, }).catch(() => {}); return { success: false, status: 500, error: errorText }; } const timeoutMs = normalizePositiveNumber( body.timeout_ms, normalizePositiveNumber(process.env.NANOBANANA_POLL_TIMEOUT_MS, 120000) ); const pollIntervalMs = normalizePositiveNumber( body.poll_interval_ms, normalizePositiveNumber(process.env.NANOBANANA_POLL_INTERVAL_MS, 2500) ); let lastTaskData = null; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const pollResp = await fetch(`${statusUrl}?taskId=${encodeURIComponent(taskId)}`, { method: "GET", headers: { Authorization: `Bearer ${token}` }, }); if (!pollResp.ok) { const errorText = await pollResp.text(); if (log) { log.error( "IMAGE", `${provider} poll error ${pollResp.status}: ${errorText.slice(0, 200)}` ); } return { success: false, status: pollResp.status, error: errorText }; } const pollData = await pollResp.json(); const taskData = pollData?.data || pollData; lastTaskData = taskData; const successFlag = Number(taskData?.successFlag); if (successFlag === 1) { const normalized = await normalizeNanoBananaTaskResult(taskData, body, log); saveCallLog({ method: "POST", path: "/v1/images/generations", status: 200, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, responseBody: { images_count: normalized.length, mode: "async", taskId }, }).catch(() => {}); return { success: true, data: { created: Math.floor(Date.now() / 1000), data: normalized, }, }; } if (successFlag === 2 || successFlag === 3) { const errorText = taskData?.errorMessage || `NanoBanana task failed (successFlag=${String(successFlag)})`; saveCallLog({ method: "POST", path: "/v1/images/generations", status: 502, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, error: errorText.slice(0, 500), responseBody: { taskId, successFlag, errorCode: taskData?.errorCode ?? null }, }).catch(() => {}); return { success: false, status: 502, error: errorText }; } await sleep(pollIntervalMs); } const timeoutError = `NanoBanana task timeout after ${timeoutMs}ms (taskId=${taskId}, successFlag=${String(lastTaskData?.successFlag ?? "unknown")})`; saveCallLog({ method: "POST", path: "/v1/images/generations", status: 504, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, error: timeoutError, responseBody: { taskId, lastSuccessFlag: lastTaskData?.successFlag ?? null }, }).catch(() => {}); return { success: false, status: 504, error: timeoutError }; } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); saveCallLog({ method: "POST", path: "/v1/images/generations", status: 502, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, error: err.message, }).catch(() => {}); return { success: false, status: 502, error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, }; } } function normalizeNanoBananaSyncPayload(data, prompt) { const images = []; if (data.image) { images.push({ b64_json: data.image, revised_prompt: prompt }); } else if (Array.isArray(data.images)) { for (const img of data.images) { images.push({ b64_json: typeof img === "string" ? img : img?.image || img?.data, revised_prompt: prompt, }); } } else if (Array.isArray(data.data)) { for (const img of data.data) { if (!img) continue; images.push(img); } } return { data: images.filter(Boolean) }; } async function normalizeNanoBananaTaskResult(taskData, body, log) { const response = taskData?.response || {}; const urlCandidates = [ response?.resultImageUrl, response?.originImageUrl, taskData?.resultImageUrl, taskData?.originImageUrl, ].filter((v) => typeof v === "string" && v.length > 0); if (Array.isArray(response?.resultImageUrls)) { for (const u of response.resultImageUrls) { if (typeof u === "string" && u.length > 0) urlCandidates.push(u); } } const b64Candidates = [ response?.resultImageBase64, response?.resultImage, taskData?.resultImageBase64, taskData?.resultImage, ].filter((v) => typeof v === "string" && v.length > 0); if (Array.isArray(response?.resultImageBase64List)) { for (const b64 of response.resultImageBase64List) { if (typeof b64 === "string" && b64.length > 0) b64Candidates.push(b64); } } const wantsBase64 = body.response_format === "b64_json"; if (wantsBase64) { if (b64Candidates.length > 0) { return b64Candidates.map((b64) => ({ b64_json: b64, revised_prompt: body.prompt })); } if (urlCandidates.length > 0) { const firstUrl = urlCandidates[0]; const remoteImage = await fetchRemoteImage(firstUrl); const base64 = remoteImage.buffer.toString("base64"); return [{ b64_json: base64, revised_prompt: body.prompt }]; } } if (urlCandidates.length > 0) { return urlCandidates.map((url) => ({ url, revised_prompt: body.prompt })); } if (b64Candidates.length > 0) { return b64Candidates.map((b64) => ({ b64_json: b64, revised_prompt: body.prompt })); } if (log) { log.warn( "IMAGE", `NanoBanana task completed without image payload: ${JSON.stringify(taskData).slice(0, 240)}` ); } return []; } function inferResolutionFromSize(size) { if (typeof size !== "string") return null; const [wRaw, hRaw] = size.split("x"); const width = Number(wRaw); const height = Number(hRaw); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; const longestSide = Math.max(width, height); if (longestSide <= 1024) return "1K"; if (longestSide <= 2048) return "2K"; return "4K"; } function normalizePositiveNumber(value, fallback) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return fallback; return Math.floor(n); } /** * Handle SD WebUI image generation (local, no auth) * POST {baseUrl} with { prompt, negative_prompt, width, height, steps } * Response: { images: ["base64..."] } */