mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Error message sanitization (Hard Rule #12): - claude-auth/export, codex-auth/export, gemini-cli-auth/export routes: replace raw err.message with sanitizeErrorMessage() from open-sse/utils/error.ts - imageGeneration, musicGeneration, videoGeneration handlers: import sanitizeErrorMessage and replace all err.message in return values - veoaifree-web executor: replace raw upstream response data in errResp() calls with static strings OAuth callback page (callback/page.tsx): - Remove useSearchParams/Suspense dependency that caused hydration failures in popup windows navigating back from Google OAuth (COOP header severs opener) - Use window.location.search directly in useEffect with three send methods: postMessage, BroadcastChannel, localStorage - Fix postMessage target from "*" to window.location.origin (semgrep finding) - Move setCurrentUrl call to manual-only branch to avoid unnecessary renders copilot-web executor: - Move accessToken from WebSocket URL query string to Authorization header (avoids credential exposure in server logs) - Add MAX_POOL_SIZE=100 cap to sessionPool with LRU eviction of oldest entry CodeQL ReDoS fixes (js/polynomial-redos #233-240): - Replace while(s.endsWith("/")) s=s.slice(0,-1) pattern (O(n²) allocations) with index-based loop (O(n) time, single final slice) in: bin/cli/api.mjs, all 6 cli-helper config generators, opencode-provider Gemini OAuth: - mapTokens: add idToken field to fix "missing id_token" export error
3486 lines
100 KiB
TypeScript
3486 lines
100 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 { 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 { sanitizeErrorMessage } from "../utils/error.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 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"]);
|
|
|
|
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,
|
|
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 === "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,
|
|
});
|
|
}
|
|
|
|
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}/${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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
const CHATGPT_WEB_IMAGE_MARKDOWN_RE = /!\[[^\]]*\]\(([^)\s]+)\)/g;
|
|
const CHATGPT_WEB_IMAGE_ID_RE = /\/v1\/chatgpt-web\/image\/([a-f0-9]{16,64})(?=[?\s"'<>)]|$)/i;
|
|
|
|
function extractMarkdownImageUrls(text: string): string[] {
|
|
const urls: string[] = [];
|
|
// String.prototype.matchAll consumes a fresh iterator and ignores the
|
|
// regex's lastIndex, so no manual reset is required.
|
|
for (const match of text.matchAll(CHATGPT_WEB_IMAGE_MARKDOWN_RE)) {
|
|
if (match[1]) urls.push(match[1]);
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
function buildChatGptWebImagePrompt(body): string {
|
|
const prompt = String(body.prompt || "").trim();
|
|
const details: string[] = [`Create an image for this prompt: ${prompt}`];
|
|
if (typeof body.size === "string" && body.size.trim()) {
|
|
details.push(`Requested size: ${body.size.trim()}.`);
|
|
}
|
|
if (typeof body.quality === "string" && body.quality.trim()) {
|
|
details.push(`Requested quality: ${body.quality.trim()}.`);
|
|
}
|
|
if (typeof body.style === "string" && body.style.trim()) {
|
|
details.push(`Requested style: ${body.style.trim()}.`);
|
|
}
|
|
return details.join("\n");
|
|
}
|
|
|
|
async function handleChatGptWebImageGeneration({
|
|
model,
|
|
provider,
|
|
body,
|
|
credentials,
|
|
log,
|
|
signal,
|
|
clientHeaders,
|
|
}) {
|
|
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 ChatGPT Web image generation",
|
|
});
|
|
}
|
|
|
|
if (!credentials?.apiKey) {
|
|
return saveImageErrorResult({
|
|
provider,
|
|
model,
|
|
status: 401,
|
|
startTime,
|
|
error: "ChatGPT Web credentials missing session cookie",
|
|
});
|
|
}
|
|
|
|
// Each image is one chatgpt.com chat turn (~30s). Cap at 4 (matches OpenAI's
|
|
// own limit for GPT Image models) so a stray n=1000 doesn't pin the
|
|
// executor for hours before the upstream HTTP timeout fires.
|
|
const CHATGPT_WEB_IMAGE_N_MAX = 4;
|
|
const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1;
|
|
if (rawCount > CHATGPT_WEB_IMAGE_N_MAX) {
|
|
return saveImageErrorResult({
|
|
provider,
|
|
model,
|
|
status: 400,
|
|
startTime,
|
|
error: `ChatGPT Web image generation supports n=1..${CHATGPT_WEB_IMAGE_N_MAX} (got ${rawCount}); each n is a separate ~30s chat turn.`,
|
|
});
|
|
}
|
|
const requestedCount = rawCount;
|
|
if (log && requestedCount > 1) {
|
|
log.warn(
|
|
"IMAGE",
|
|
`ChatGPT Web returns one image per chat turn; requested n=${requestedCount} will run sequentially`
|
|
);
|
|
}
|
|
|
|
const wantsBase64 = body.response_format === "b64_json";
|
|
const images: Array<{ url?: string; b64_json?: string }> = [];
|
|
const requestBody = {
|
|
model,
|
|
prompt: prompt.slice(0, 500),
|
|
size: body.size || undefined,
|
|
quality: body.quality || undefined,
|
|
};
|
|
|
|
for (let i = 0; i < requestedCount; i++) {
|
|
const executor = new ChatGptWebExecutor();
|
|
const result = await executor.execute({
|
|
model,
|
|
body: {
|
|
messages: [{ role: "user", content: buildChatGptWebImagePrompt(body) }],
|
|
},
|
|
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 completed without returning image markdown: ${content.slice(0, 300)}`,
|
|
requestBody,
|
|
});
|
|
}
|
|
|
|
for (const url of urls) {
|
|
if (!wantsBase64) {
|
|
images.push({ url });
|
|
continue;
|
|
}
|
|
const id = url.match(CHATGPT_WEB_IMAGE_ID_RE)?.[1];
|
|
const cached = id ? getChatGptImage(id) : null;
|
|
if (!cached) {
|
|
return saveImageErrorResult({
|
|
provider,
|
|
model,
|
|
status: 502,
|
|
startTime,
|
|
error: "ChatGPT Web image bytes expired before b64_json conversion",
|
|
requestBody,
|
|
});
|
|
}
|
|
images.push({ b64_json: cached.bytes.toString("base64") });
|
|
}
|
|
}
|
|
|
|
return saveImageSuccessResult({
|
|
provider,
|
|
model,
|
|
startTime,
|
|
requestBody,
|
|
responseBody: { images_count: images.length },
|
|
images,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle a multipart /v1/images/edits request for chatgpt-web. Open WebUI
|
|
* uploads the prior image's bytes; we hash them and look up our cache.
|
|
*
|
|
* The hash match is reliable because Open WebUI's image-gen pipeline
|
|
* downloads our /v1/chatgpt-web/image/<id> URL byte-for-byte and re-serves
|
|
* those exact bytes through its own file store. When the user asks to edit
|
|
* the image, OWUI uploads the same bytes back to us via multipart — same
|
|
* hash, we find the conversation context, and drive the executor with a
|
|
* synthetic chat thread that triggers continuation mode.
|
|
*
|
|
* No-match cases (cache evicted by TTL, or the user uploaded a foreign
|
|
* image) get a clear 400. We can't actually edit an image we don't have a
|
|
* conversation context for — chatgpt.com's image_gen tool needs the
|
|
* original conversation node, and we don't have a path to upload bytes
|
|
* directly.
|
|
*/
|
|
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: sanitizeErrorMessage(err) || "Image provider error",
|
|
});
|
|
}
|
|
}
|
|
|
|
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: sanitizeErrorMessage(err) || "Image provider error",
|
|
});
|
|
}
|
|
}
|
|
|
|
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: sanitizeErrorMessage(err) || "Image provider error",
|
|
});
|
|
}
|
|
}
|
|
|
|
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: sanitizeErrorMessage(err) || "Image provider error",
|
|
});
|
|
}
|
|
}
|
|
|
|
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: sanitizeErrorMessage(err) || "Image provider error",
|
|
});
|
|
}
|
|
}
|
|
|
|
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 run sequentially`
|
|
);
|
|
}
|
|
|
|
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 collected: Array<{ b64_json: string; revised_prompt?: string }> = [];
|
|
for (let i = 0; i < requestedCount; i++) {
|
|
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 saveImageErrorResult({
|
|
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 saveImageErrorResult({
|
|
provider,
|
|
model,
|
|
status: response.status,
|
|
startTime,
|
|
error: errorText,
|
|
requestBody: upstreamBody,
|
|
});
|
|
}
|
|
|
|
const rawSSE = await response.text();
|
|
const items = extractImageGenerationCalls(rawSSE);
|
|
if (items.length === 0) {
|
|
return saveImageErrorResult({
|
|
provider,
|
|
model,
|
|
status: 502,
|
|
startTime,
|
|
error:
|
|
"Codex completed without producing an image_generation_call — the model may have declined the tool",
|
|
requestBody: upstreamBody,
|
|
});
|
|
}
|
|
for (const item of 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,
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
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 {
|
|
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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
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..."] }
|
|
*/
|
|
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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleHaiperImageGeneration({
|
|
model,
|
|
provider,
|
|
providerConfig,
|
|
body,
|
|
credentials,
|
|
log,
|
|
}) {
|
|
const startTime = Date.now();
|
|
const token = credentials?.apiKey || "";
|
|
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
|
|
if (log) {
|
|
log.info("IMAGE", `${provider}/${model} (haiper) | prompt: "${prompt.slice(0, 60)}..."`);
|
|
}
|
|
try {
|
|
const res = await fetch(providerConfig.baseUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", HAIPER_KEY: token },
|
|
body: JSON.stringify({ prompt, aspect_ratio: body.aspect_ratio || "16:9" }),
|
|
});
|
|
if (!res.ok) {
|
|
const errorText = await res.text();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: res.status,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
}).catch(() => {});
|
|
return { success: false, status: res.status, error: errorText };
|
|
}
|
|
const { job_id } = await res.json();
|
|
const deadline = Date.now() + 300000;
|
|
while (Date.now() < deadline) {
|
|
await sleep(5000);
|
|
const statusRes = await fetch(`${providerConfig.statusUrl}/${job_id}`, {
|
|
headers: { HAIPER_KEY: token },
|
|
});
|
|
const status = await statusRes.json();
|
|
if (status.status === "completed" || status.status === "succeeded") {
|
|
const imgUrl = status.creation_url || status.output?.image_url;
|
|
if (imgUrl) {
|
|
const imgRes = await fetch(imgUrl);
|
|
if (!imgRes.ok) {
|
|
return {
|
|
success: false,
|
|
status: imgRes.status,
|
|
error: `Failed to download image: ${imgRes.status}`,
|
|
};
|
|
}
|
|
const buf = await imgRes.arrayBuffer();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
}).catch(() => {});
|
|
return {
|
|
success: true,
|
|
data: {
|
|
created: Math.floor(Date.now() / 1000),
|
|
data: [{ b64_json: Buffer.from(buf).toString("base64") }],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (status.status === "failed") {
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "Haiper image generation failed",
|
|
}).catch(() => {});
|
|
return { success: false, status: 502, error: "Haiper image generation failed" };
|
|
}
|
|
}
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 504,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "Haiper image generation timed out",
|
|
}).catch(() => {});
|
|
return { success: false, status: 504, error: "Haiper image generation timed out" };
|
|
} catch (err) {
|
|
if (log) log.error("IMAGE", `${provider} haiper 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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleLeonardoImageGeneration({
|
|
model,
|
|
provider,
|
|
providerConfig,
|
|
body,
|
|
credentials,
|
|
log,
|
|
}) {
|
|
const startTime = Date.now();
|
|
const token = credentials?.apiKey || "";
|
|
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
|
|
if (log) {
|
|
log.info("IMAGE", `${provider}/${model} (leonardo) | prompt: "${prompt.slice(0, 60)}..."`);
|
|
}
|
|
try {
|
|
const res = await fetch(providerConfig.baseUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({
|
|
modelId: model || "phoenix",
|
|
prompt,
|
|
width: body.width || 1024,
|
|
height: body.height || 1024,
|
|
num_images: 1,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const errorText = await res.text();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: res.status,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
}).catch(() => {});
|
|
return { success: false, status: res.status, error: errorText };
|
|
}
|
|
const { sdGenerationJob } = await res.json();
|
|
const genId = sdGenerationJob?.generationId;
|
|
if (!genId) {
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "No generation ID returned",
|
|
}).catch(() => {});
|
|
return { success: false, status: 502, error: "No generation ID returned" };
|
|
}
|
|
const deadline = Date.now() + 300000;
|
|
while (Date.now() < deadline) {
|
|
await sleep(5000);
|
|
const statusRes = await fetch(`${providerConfig.baseUrl}/${genId}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
const status = await statusRes.json();
|
|
const gen = status.generations_by_pk || status;
|
|
if (gen.status === "COMPLETE") {
|
|
const imgUrl = gen.generated_images?.[0]?.url;
|
|
if (imgUrl) {
|
|
const imgRes = await fetch(imgUrl);
|
|
if (!imgRes.ok) {
|
|
return {
|
|
success: false,
|
|
status: imgRes.status,
|
|
error: `Failed to download image: ${imgRes.status}`,
|
|
};
|
|
}
|
|
const buf = await imgRes.arrayBuffer();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
}).catch(() => {});
|
|
return {
|
|
success: true,
|
|
data: {
|
|
created: Math.floor(Date.now() / 1000),
|
|
data: [{ b64_json: Buffer.from(buf).toString("base64") }],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (gen.status === "FAILED") {
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "Leonardo image generation failed",
|
|
}).catch(() => {});
|
|
return { success: false, status: 502, error: "Leonardo image generation failed" };
|
|
}
|
|
}
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 504,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "Leonardo image generation timed out",
|
|
}).catch(() => {});
|
|
return { success: false, status: 504, error: "Leonardo image generation timed out" };
|
|
} catch (err) {
|
|
if (log) log.error("IMAGE", `${provider} leonardo 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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleIdeogramImageGeneration({
|
|
model,
|
|
provider,
|
|
providerConfig,
|
|
body,
|
|
credentials,
|
|
log,
|
|
}) {
|
|
const startTime = Date.now();
|
|
const token = credentials?.apiKey || "";
|
|
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
|
|
if (log) {
|
|
log.info("IMAGE", `${provider}/${model} (ideogram) | prompt: "${prompt.slice(0, 60)}..."`);
|
|
}
|
|
try {
|
|
const res = await fetch(providerConfig.baseUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Api-Key": token },
|
|
body: JSON.stringify({ prompt, aspect_ratio: "ASPECT_16_9", model: model || "V_3" }),
|
|
});
|
|
if (!res.ok) {
|
|
const errorText = await res.text();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: res.status,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
}).catch(() => {});
|
|
return { success: false, status: res.status, error: errorText };
|
|
}
|
|
const data = await res.json();
|
|
if (data.data && data.data.length > 0) {
|
|
const imgUrl = data.data[0].url;
|
|
const imgRes = await fetch(imgUrl);
|
|
if (!imgRes.ok) {
|
|
return {
|
|
success: false,
|
|
status: imgRes.status,
|
|
error: `Failed to download image: ${imgRes.status}`,
|
|
};
|
|
}
|
|
const buf = await imgRes.arrayBuffer();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
}).catch(() => {});
|
|
return {
|
|
success: true,
|
|
data: {
|
|
created: Math.floor(Date.now() / 1000),
|
|
data: [{ b64_json: Buffer.from(buf).toString("base64") }],
|
|
},
|
|
};
|
|
}
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/images/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "No images returned from Ideogram",
|
|
}).catch(() => {});
|
|
return { success: false, status: 502, error: "No images returned from Ideogram" };
|
|
} catch (err) {
|
|
if (log) log.error("IMAGE", `${provider} ideogram 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: sanitizeErrorMessage(err) || "Image provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
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}` };
|
|
}
|
|
}
|