Merge pull request #247 from hijak/fix/nanobanana-async

## Review

 **Aprovado** — implementação clara e bem estruturada.

**O que a PR faz:**
- Implementa o fluxo async correto da NanoBanana API: submit task → poll /record-info until successFlag=1
- Mantém backward compatibility para providers retornando payload síncrono
- Infere aspectRatio e resolution do campo size (padrão OpenAI)
- Permite tunagem via env vars (NANOBANANA_POLL_TIMEOUT_MS, NANOBANANA_POLL_INTERVAL_MS)
- Adiciona 2 arquivos de teste com cobertura específica

**Qualidade:**
- Código limpo e bem documentado
- Testes adicionados e passando localmente
- Não quebra implementações existentes (sync path preservado)

Obrigado pela contribuição @hijak! 🎉
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-03-08 17:59:11 -03:00
committed by GitHub
4 changed files with 559 additions and 34 deletions

View File

@@ -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: {

View File

@@ -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 }

View File

@@ -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;
}
});

View File

@@ -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;
}
});