From 9a476d310aadceab03e931ed1d3ba6b1720972be Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 8 Mar 2026 20:47:17 +0000 Subject: [PATCH] fix(images): support async NanoBanana task polling --- open-sse/config/imageRegistry.ts | 5 +- open-sse/handlers/imageGeneration.ts | 336 ++++++++++++++++-- .../unit/nanobanana-image-generation.test.mjs | 128 +++++++ tests/unit/nanobanana-image-handler.test.mjs | 124 +++++++ 4 files changed, 559 insertions(+), 34 deletions(-) create mode 100644 tests/unit/nanobanana-image-generation.test.mjs create mode 100644 tests/unit/nanobanana-image-handler.test.mjs diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index b18a86599f..ed5a51c9ea 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -99,14 +99,15 @@ export const IMAGE_PROVIDERS = { id: "nanobanana", baseUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", proUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate-pro", + statusUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info", authType: "apikey", authHeader: "bearer", - format: "nanobanana", // custom format + format: "nanobanana", // custom format (async: submit task, then poll) models: [ { id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" }, { id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" }, ], - supportedSizes: ["1024x1024"], + supportedSizes: ["1024x1024", "1024x1280", "1024x1536", "1536x1024", "1280x1024"], }, sdwebui: { diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 7e7a15ae7c..6fc6f29519 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -516,7 +516,7 @@ async function handleHyperbolicImageGeneration({ /** * Handle NanoBanana image generation - * Uses flash vs pro routing based on model ID + * NanoBanana is async (submit task -> poll status -> return final image URL/base64) */ async function handleNanoBananaImageGeneration({ model, @@ -531,11 +531,41 @@ async function handleNanoBananaImageGeneration({ // Route to pro URL for "nanobanana-pro" model const isPro = model === "nanobanana-pro"; - const url = isPro && providerConfig.proUrl ? providerConfig.proUrl : providerConfig.baseUrl; + const submitUrl = isPro && providerConfig.proUrl ? providerConfig.proUrl : providerConfig.baseUrl; + const statusUrl = providerConfig.statusUrl; - const upstreamBody = { - prompt: body.prompt, - }; + const aspectRatio = + typeof body.aspectRatio === "string" + ? body.aspectRatio + : typeof body.aspect_ratio === "string" + ? body.aspect_ratio + : inferAspectRatioFromSize(body.size) || "1:1"; + + 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); @@ -546,7 +576,7 @@ async function handleNanoBananaImageGeneration({ } try { - const response = await fetch(url, { + const submitResp = await fetch(submitUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -555,55 +585,174 @@ async function handleNanoBananaImageGeneration({ 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)}`); + 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: response.status, + status: submitResp.status, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, error: errorText.slice(0, 500), }).catch(() => {}); - return { success: false, status: response.status, error: errorText }; + return { success: false, status: submitResp.status, error: errorText }; } - const data = await response.json(); - // Normalize NanoBanana response to OpenAI format - const images = []; - if (data.image) { - images.push({ b64_json: data.image, revised_prompt: body.prompt }); - } else if (data.images) { - for (const img of data.images) { - images.push({ - b64_json: typeof img === "string" ? img : img.image || img.data, - revised_prompt: body.prompt, - }); + 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 }; } - } else if (data.data) { - // Already OpenAI-like - return { success: true, data }; + + 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: 200, + status: 504, model: `${provider}/${model}`, provider, duration: Date.now() - startTime, - responseBody: { images_count: images.length }, + error: timeoutError, + responseBody: { taskId, lastSuccessFlag: lastTaskData?.successFlag ?? null }, }).catch(() => {}); - return { - success: true, - data: { created: Math.floor(Date.now() / 1000), data: images }, - }; + return { success: false, status: 504, error: timeoutError }; } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); saveCallLog({ @@ -619,6 +768,129 @@ async function handleNanoBananaImageGeneration({ } } +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 resp = await fetch(firstUrl); + if (!resp.ok) { + throw new Error(`Failed to fetch NanoBanana result image URL (${resp.status})`); + } + const arrayBuffer = await resp.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).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 inferAspectRatioFromSize(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 gcd = (a, b) => (b === 0 ? a : gcd(b, a % b)); + const div = gcd(Math.round(width), Math.round(height)); + return `${Math.round(width / div)}:${Math.round(height / div)}`; +} + +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); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + /** * Handle SD WebUI image generation (local, no auth) * POST {baseUrl} with { prompt, negative_prompt, width, height, steps } diff --git a/tests/unit/nanobanana-image-generation.test.mjs b/tests/unit/nanobanana-image-generation.test.mjs new file mode 100644 index 0000000000..d2d10d72a6 --- /dev/null +++ b/tests/unit/nanobanana-image-generation.test.mjs @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function inferAspectRatioFromSize(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 gcd = (a, b) => (b === 0 ? a : gcd(b, a % b)); + const div = gcd(Math.round(width), Math.round(height)); + return `${Math.round(width / div)}:${Math.round(height / div)}`; +} + +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"; +} + +test("nanobanana pro payload inference maps size to aspectRatio and resolution", () => { + const aspectRatio = inferAspectRatioFromSize("1024x1280"); + const resolution = inferResolutionFromSize("1024x1280"); + + assert.equal(aspectRatio, "4:5"); + assert.equal(resolution, "2K"); +}); + +test("nanobanana async flow (submit->poll->url) normalizes to OpenAI-style url item", async () => { + const calls = []; + + const fetchMock = async (url, options = {}) => { + calls.push({ url: String(url), method: options.method || "GET", body: options.body }); + + if (String(url).includes("/generate")) { + return new Response(JSON.stringify({ code: 200, msg: "success", data: { taskId: "t-1" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (String(url).includes("/record-info")) { + const polls = calls.filter((c) => c.url.includes("/record-info")).length; + if (polls < 2) { + return new Response( + JSON.stringify({ code: 200, msg: "success", data: { successFlag: 0 } }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + + return new Response( + JSON.stringify({ + code: 200, + msg: "success", + data: { + successFlag: 1, + response: { resultImageUrl: "https://cdn.example.com/final.jpg" }, + }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + + throw new Error(`Unexpected URL ${url}`); + }; + + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = fetchMock; + + const submit = await fetch("https://api.nanobananaapi.ai/api/v1/nanobanana/generate", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer test" }, + body: JSON.stringify({ prompt: "space" }), + }); + const submitData = await submit.json(); + const taskId = submitData.data.taskId; + + let finalData; + for (let i = 0; i < 5; i++) { + const poll = await fetch( + `https://api.nanobananaapi.ai/api/v1/nanobanana/record-info?taskId=${encodeURIComponent(taskId)}` + ); + const pollData = await poll.json(); + if (pollData.data.successFlag === 1) { + finalData = pollData.data; + break; + } + } + + assert.ok(finalData); + assert.equal(finalData.response.resultImageUrl, "https://cdn.example.com/final.jpg"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("nanobanana b64 mode can convert result URL bytes to b64_json", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async (url) => { + if (String(url).includes("https://cdn.example.com/final.jpg")) { + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 }); + } + throw new Error(`Unexpected URL ${url}`); + }; + + try { + const resp = await fetch("https://cdn.example.com/final.jpg"); + const buf = Buffer.from(await resp.arrayBuffer()); + const b64 = buf.toString("base64"); + assert.equal(b64, "iVBORw=="); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/nanobanana-image-handler.test.mjs b/tests/unit/nanobanana-image-handler.test.mjs new file mode 100644 index 0000000000..fc13983ecf --- /dev/null +++ b/tests/unit/nanobanana-image-handler.test.mjs @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts"; + +test("handleImageGeneration(nanobanana): async submit+poll returns URL payload", async () => { + const originalFetch = globalThis.fetch; + let pollCount = 0; + + globalThis.fetch = async (url, options = {}) => { + const u = String(url); + + if (u.includes("/generate-pro")) { + const body = JSON.parse(options.body); + assert.equal(body.prompt, "galaxy test"); + assert.equal(body.aspectRatio, "4:5"); + assert.equal(body.resolution, "2K"); + + return new Response( + JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-handler-1" } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u.includes("/record-info")) { + pollCount += 1; + if (pollCount < 2) { + return new Response( + JSON.stringify({ code: 200, msg: "success", data: { successFlag: 0 } }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + + return new Response( + JSON.stringify({ + code: 200, + msg: "success", + data: { + successFlag: 1, + response: { resultImageUrl: "https://cdn.example.com/handler-result.jpg" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + throw new Error(`Unexpected URL: ${u}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "nanobanana/nanobanana-pro", + prompt: "galaxy test", + size: "1024x1280", + poll_interval_ms: 1, + }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(result.data.data.length, 1); + assert.equal(result.data.data[0].url, "https://cdn.example.com/handler-result.jpg"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration(nanobanana): response_format=b64_json converts URL to b64", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async (url) => { + const u = String(url); + + if (u.includes("/generate")) { + return new Response( + JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-handler-2" } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u.includes("/record-info")) { + return new Response( + JSON.stringify({ + code: 200, + msg: "success", + data: { + successFlag: 1, + response: { resultImageUrl: "https://cdn.example.com/handler-result-2.jpg" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u.includes("handler-result-2.jpg")) { + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 }); + } + + throw new Error(`Unexpected URL: ${u}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "nanobanana/nanobanana-flash", + prompt: "galaxy test", + response_format: "b64_json", + }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(result.data.data.length, 1); + assert.equal(result.data.data[0].b64_json, "iVBORw=="); + } finally { + globalThis.fetch = originalFetch; + } +});