mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
- Reword comments that contained the token any; replace any types with typed shapes - stream.ts: passthrough tool-call flag via local boolean (state is null in passthrough) - Document T11 in zws_docs/ZWS_README_V8.md Made-with: Cursor
1209 lines
34 KiB
TypeScript
1209 lines
34 KiB
TypeScript
/**
|
|
* 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/dall-e-3",
|
|
* "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 { mapImageSize } from "../translator/image/sizeMapper.ts";
|
|
import { saveCallLog } from "@/lib/usageDb";
|
|
import {
|
|
submitComfyWorkflow,
|
|
pollComfyResult,
|
|
fetchComfyOutput,
|
|
extractComfyOutputFiles,
|
|
} from "../utils/comfyuiClient.ts";
|
|
|
|
/**
|
|
* 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 }) {
|
|
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,
|
|
baseUrl:
|
|
credentials?.baseUrl ||
|
|
`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 === "nanobanana") {
|
|
return handleNanoBananaImageGeneration({
|
|
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 });
|
|
}
|
|
|
|
return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log });
|
|
}
|
|
|
|
/**
|
|
* 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}/${model}:generateContent`;
|
|
const provider = "antigravity";
|
|
|
|
// 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,
|
|
};
|
|
|
|
const geminiBody = {
|
|
contents: [
|
|
{
|
|
parts: [{ text: body.prompt }],
|
|
},
|
|
],
|
|
generationConfig: {
|
|
responseModalities: ["TEXT", "IMAGE"],
|
|
},
|
|
};
|
|
|
|
const token = credentials.accessToken || credentials.apiKey;
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
};
|
|
|
|
if (log) {
|
|
const promptPreview =
|
|
typeof body.prompt === "string"
|
|
? body.prompt.slice(0, 60)
|
|
: String(body.prompt ?? "").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(geminiBody),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
if (log) {
|
|
log.error("IMAGE", `antigravity error ${response.status}: ${errorText.slice(0, 200)}`);
|
|
}
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: response.status,
|
|
model: `antigravity/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
requestBody: logRequestBody,
|
|
}).catch(() => {});
|
|
|
|
return { success: false, status: response.status, error: errorText };
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Extract image data from Gemini response
|
|
const images = [];
|
|
const candidates = data.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 || body.prompt,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string, unknown> = {
|
|
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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Fetch a single image endpoint and normalize response
|
|
*/
|
|
async function fetchImageEndpoint(url, headers, body, provider, log) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body,
|
|
});
|
|
|
|
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) {
|
|
if (log) {
|
|
log.error("IMAGE", `${provider} fetch error: ${err.message}`);
|
|
}
|
|
return {
|
|
success: false,
|
|
status: 502,
|
|
error: `Image provider error: ${err.message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Hyperbolic image generation
|
|
* Uses { model_name, prompt, height, width } and returns { images: [{ image: base64 }] }
|
|
*/
|
|
async function handleHyperbolicImageGeneration({
|
|
model,
|
|
provider,
|
|
providerConfig,
|
|
body,
|
|
credentials,
|
|
log,
|
|
}) {
|
|
const startTime = Date.now();
|
|
const token = credentials.apiKey || credentials.accessToken;
|
|
|
|
const [width, height] = (body.size || "1024x1024").split("x").map(Number);
|
|
|
|
const upstreamBody = {
|
|
model_name: model,
|
|
prompt: body.prompt,
|
|
height: height || 1024,
|
|
width: width || 1024,
|
|
backend: "auto",
|
|
};
|
|
|
|
if (log) {
|
|
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
|
log.info("IMAGE", `${provider}/${model} (hyperbolic) | prompt: "${promptPreview}..."`);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(providerConfig.baseUrl, {
|
|
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)}`);
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: response.status,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
}).catch(() => {});
|
|
|
|
return { success: false, status: response.status, error: errorText };
|
|
}
|
|
|
|
const data = await response.json();
|
|
// Transform { images: [{ image: base64 }] } → OpenAI format
|
|
const images = (data.images || []).map((img) => ({
|
|
b64_json: img.image,
|
|
revised_prompt: body.prompt,
|
|
}));
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
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", `${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: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle NanoBanana image generation
|
|
* NanoBanana is async (submit task -> poll status -> return final image URL/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: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
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 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 }
|
|
* Response: { images: ["base64..."] }
|
|
*/
|
|
async function handleSDWebUIImageGeneration({ model, provider, providerConfig, body, log }) {
|
|
const startTime = Date.now();
|
|
const [width, height] = (body.size || "512x512").split("x").map(Number);
|
|
|
|
const upstreamBody = {
|
|
prompt: body.prompt,
|
|
negative_prompt: body.negative_prompt || "",
|
|
width: width || 512,
|
|
height: height || 512,
|
|
steps: body.steps || 20,
|
|
cfg_scale: body.cfg_scale || 7,
|
|
sampler_name: body.sampler || "Euler a",
|
|
batch_size: body.n || 1,
|
|
override_settings: {
|
|
sd_model_checkpoint: model,
|
|
},
|
|
};
|
|
|
|
if (log) {
|
|
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
|
log.info("IMAGE", `${provider}/${model} (sdwebui) | prompt: "${promptPreview}..."`);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(providerConfig.baseUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
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)}`);
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: response.status,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
}).catch(() => {});
|
|
|
|
return { success: false, status: response.status, error: errorText };
|
|
}
|
|
|
|
const data = await response.json();
|
|
// SD WebUI returns { images: ["base64...", ...] }
|
|
const images = (data.images || []).map((b64) => ({
|
|
b64_json: b64,
|
|
revised_prompt: body.prompt,
|
|
}));
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
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", `${provider} sdwebui 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: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle ComfyUI image generation (local, no auth)
|
|
* Submits a txt2img workflow, polls for completion, fetches output
|
|
*/
|
|
async function handleComfyUIImageGeneration({ model, provider, providerConfig, body, log }) {
|
|
const startTime = Date.now();
|
|
const [width, height] = (body.size || "1024x1024").split("x").map(Number);
|
|
|
|
// Default txt2img workflow template for ComfyUI
|
|
const workflow = {
|
|
"3": {
|
|
class_type: "KSampler",
|
|
inputs: {
|
|
seed: Math.floor(Math.random() * 2 ** 32),
|
|
steps: body.steps || 20,
|
|
cfg: body.cfg_scale || 7,
|
|
sampler_name: "euler",
|
|
scheduler: "normal",
|
|
denoise: 1,
|
|
model: ["4", 0],
|
|
positive: ["6", 0],
|
|
negative: ["7", 0],
|
|
latent_image: ["5", 0],
|
|
},
|
|
},
|
|
"4": {
|
|
class_type: "CheckpointLoaderSimple",
|
|
inputs: { ckpt_name: model },
|
|
},
|
|
"5": {
|
|
class_type: "EmptyLatentImage",
|
|
inputs: { width: width || 1024, height: height || 1024, batch_size: body.n || 1 },
|
|
},
|
|
"6": {
|
|
class_type: "CLIPTextEncode",
|
|
inputs: { text: body.prompt, clip: ["4", 1] },
|
|
},
|
|
"7": {
|
|
class_type: "CLIPTextEncode",
|
|
inputs: { text: body.negative_prompt || "", clip: ["4", 1] },
|
|
},
|
|
"8": {
|
|
class_type: "VAEDecode",
|
|
inputs: { samples: ["3", 0], vae: ["4", 2] },
|
|
},
|
|
"9": {
|
|
class_type: "SaveImage",
|
|
inputs: { filename_prefix: "omniroute", images: ["8", 0] },
|
|
},
|
|
};
|
|
|
|
if (log) {
|
|
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
|
log.info("IMAGE", `${provider}/${model} (comfyui) | prompt: "${promptPreview}..."`);
|
|
}
|
|
|
|
try {
|
|
const promptId = await submitComfyWorkflow(providerConfig.baseUrl, workflow);
|
|
const historyEntry = await pollComfyResult(providerConfig.baseUrl, promptId);
|
|
const outputFiles = extractComfyOutputFiles(historyEntry);
|
|
|
|
const images = [];
|
|
for (const file of outputFiles) {
|
|
const buffer = await fetchComfyOutput(
|
|
providerConfig.baseUrl,
|
|
file.filename,
|
|
file.subfolder,
|
|
file.type
|
|
);
|
|
const base64 = Buffer.from(buffer).toString("base64");
|
|
images.push({ b64_json: base64, revised_prompt: body.prompt });
|
|
}
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
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", `${provider} comfyui 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: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
type Imagen3ImageGenArgs = {
|
|
model: string;
|
|
provider: string;
|
|
providerConfig: { baseUrl: string };
|
|
body: { prompt?: string; size?: string; n?: number };
|
|
credentials: { apiKey?: string; accessToken?: string };
|
|
log?: { info?: (tag: string, msg: string) => void; error?: (tag: string, msg: string) => void } | null;
|
|
};
|
|
|
|
type Imagen3NormalizedImage = {
|
|
b64_json?: unknown;
|
|
url?: unknown;
|
|
revised_prompt?: string;
|
|
};
|
|
|
|
/**
|
|
* Handle Imagen 3 image generation
|
|
*/
|
|
async function handleImagen3ImageGeneration({
|
|
model,
|
|
provider,
|
|
providerConfig,
|
|
body,
|
|
credentials,
|
|
log,
|
|
}: Imagen3ImageGenArgs) {
|
|
const startTime = Date.now();
|
|
const token = credentials.apiKey || credentials.accessToken;
|
|
const aspectRatio = mapImageSize(body.size);
|
|
|
|
const upstreamBody = {
|
|
prompt: body.prompt,
|
|
aspect_ratio: aspectRatio,
|
|
number_of_images: body.n ?? 1,
|
|
};
|
|
|
|
if (log) {
|
|
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
|
log.info(
|
|
"IMAGE",
|
|
`${provider}/${model} (imagen3) | prompt: "${promptPreview}..." | aspect_ratio: ${aspectRatio}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(providerConfig.baseUrl, {
|
|
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)}`);
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: response.status,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
requestBody: upstreamBody,
|
|
}).catch(() => {});
|
|
|
|
return { success: false, status: response.status, error: errorText };
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Normalize response to OpenAI format
|
|
const images: Imagen3NormalizedImage[] = [];
|
|
if (Array.isArray(data.images)) {
|
|
images.push(
|
|
...data.images.map((img: Record<string, unknown>) => ({
|
|
b64_json: img.image ?? img.b64_json ?? img.url ?? img,
|
|
revised_prompt: body.prompt,
|
|
}))
|
|
);
|
|
} else if (Array.isArray(data.data)) {
|
|
images.push(...data.data);
|
|
} else if (data.url || data.b64_json || data.image) {
|
|
images.push({
|
|
b64_json: data.image || data.b64_json || data.url,
|
|
url: data.url,
|
|
revised_prompt: body.prompt,
|
|
});
|
|
}
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
responseBody: { images_count: images.length },
|
|
}).catch(() => {});
|
|
|
|
return {
|
|
success: true,
|
|
data: { created: data.created || Math.floor(Date.now() / 1000), data: images },
|
|
};
|
|
} catch (err: unknown) {
|
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
if (log) log.error("IMAGE", `${provider} fetch error: ${errMsg}`);
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errMsg,
|
|
}).catch(() => {});
|
|
|
|
return { success: false, status: 502, error: `Image provider error: ${errMsg}` };
|
|
}
|
|
}
|