2880 lines
85 KiB
TypeScript
2880 lines
85 KiB
TypeScript
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<string, unknown> & {
|
|
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<string>();
|
|
|
|
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<string, unknown> = {};
|
|
|
|
if (isMarket) {
|
|
// Unified Market API endpoint
|
|
baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/createTask`;
|
|
const input: Record<string, unknown> = {
|
|
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<string, unknown>).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<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;
|
|
|
|
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<string, string> = {
|
|
"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<string, any>;
|
|
imageBytes: Buffer;
|
|
imageMime?: string; // accepted for symmetry with route layer; not used
|
|
credentials: any;
|
|
log: any;
|
|
signal?: AbortSignal | null;
|
|
clientHeaders?: Record<string, string> | 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: ``,
|
|
},
|
|
{
|
|
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<string, unknown> = {
|
|
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<string, unknown> = {
|
|
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<string, unknown> = {
|
|
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<string, unknown> = {
|
|
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<string, unknown>;
|
|
try {
|
|
evt = JSON.parse(payload) as Record<string, unknown>;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (evt?.type !== "response.output_item.done") continue;
|
|
const item = evt.item as Record<string, unknown> | 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<string, unknown>).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<string, unknown> = { 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<string, unknown> = {
|
|
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<string, string> = {
|
|
"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..."] }
|
|
*/ |