diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 3b2da49eaa..1e63e105bd 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -44,6 +44,24 @@ import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts"; +// --- Per-provider handlers (extracted to co-located files in PR-#4582-batch) --- +// Imported locally so internal callers (handleImageGeneration / handleImageEdit) +// resolve to a real binding. extractMarkdownImageUrls + CHATGPT_WEB_IMAGE_ID_RE +// are still used by handleImageEdit below, so they are imported (not re-defined). +import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts"; +import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts"; +import { handleComfyUIImageGeneration } from "./imageGeneration/providers/comfyUI.ts"; +import { handleImagen3ImageGeneration } from "./imageGeneration/providers/imagen3.ts"; +import { handleIdeogramImageGeneration } from "./imageGeneration/providers/ideogram.ts"; +import { handleHaiperImageGeneration } from "./imageGeneration/providers/haiper.ts"; +import { handleLeonardoImageGeneration } from "./imageGeneration/providers/leonardo.ts"; +import { + handleChatGptWebImageGeneration, + extractMarkdownImageUrls, + CHATGPT_WEB_IMAGE_ID_RE, +} from "./imageGeneration/providers/chatgptWeb.ts"; + + interface KieImageOptions { model: string; provider: string; @@ -1096,190 +1114,6 @@ export async function handleOpenAIImageEdit({ 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/ 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, @@ -2538,7 +2372,7 @@ async function handleCodexImageGeneration({ }); } -function saveImageSuccessResult({ +export function saveImageSuccessResult({ provider, model, startTime, @@ -2567,7 +2401,7 @@ function saveImageSuccessResult({ }; } -function saveImageErrorResult({ provider, model, status, startTime, error, requestBody = null }) { +export function saveImageErrorResult({ provider, model, status, startTime, error, requestBody = null }) { saveCallLog({ method: "POST", path: "/v1/images/generations", @@ -2658,104 +2492,6 @@ async function fetchImageEndpoint(url, headers, body, provider, log) { * Handle Hyperbolic image generation * Uses { model_name, prompt, height, width } and returns { images: [{ image: base64 }] } */ -async function handleHyperbolicImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - - const [width, height] = (body.size || "1024x1024").split("x").map(Number); - - const upstreamBody = { - model_name: model, - prompt: body.prompt, - height: height || 1024, - width: width || 1024, - backend: "auto", - }; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (hyperbolic) | prompt: "${promptPreview}..."`); - } - - try { - const response = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: response.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - - return { success: false, status: response.status, error: errorText }; - } - - const data = await response.json(); - // Transform { images: [{ image: base64 }] } → OpenAI format - const images = (data.images || []).map((img) => ({ - b64_json: img.image, - revised_prompt: body.prompt, - })); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: images.length }, - }).catch(() => {}); - - return { - success: true, - data: { created: Math.floor(Date.now() / 1000), data: images }, - }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -/** - * Handle NanoBanana image generation - * NanoBanana is async (submit task -> poll status -> return final image URL/base64) - */ async function handleNanoBananaImageGeneration({ model, provider, @@ -3117,660 +2853,4 @@ function normalizePositiveNumber(value, fallback) { * Handle SD WebUI image generation (local, no auth) * POST {baseUrl} with { prompt, negative_prompt, width, height, steps } * Response: { images: ["base64..."] } - */ -async function handleSDWebUIImageGeneration({ model, provider, providerConfig, body, log }) { - const startTime = Date.now(); - const [width, height] = (body.size || "512x512").split("x").map(Number); - - const upstreamBody = { - prompt: body.prompt, - negative_prompt: body.negative_prompt || "", - width: width || 512, - height: height || 512, - steps: body.steps || 20, - cfg_scale: body.cfg_scale || 7, - sampler_name: body.sampler || "Euler a", - batch_size: body.n || 1, - override_settings: { - sd_model_checkpoint: model, - }, - }; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (sdwebui) | prompt: "${promptPreview}..."`); - } - - try { - const response = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: response.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - - return { success: false, status: response.status, error: errorText }; - } - - const data = await response.json(); - // SD WebUI returns { images: ["base64...", ...] } - const images = (data.images || []).map((b64) => ({ - b64_json: b64, - revised_prompt: body.prompt, - })); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: images.length }, - }).catch(() => {}); - - return { - success: true, - data: { created: Math.floor(Date.now() / 1000), data: images }, - }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} sdwebui error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -/** - * 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: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -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: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -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: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -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: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -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) => ({ - 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}` }; - } -} + */ \ No newline at end of file diff --git a/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts b/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts new file mode 100644 index 0000000000..7faa531f5c --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts @@ -0,0 +1,175 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: chatgpt-web | Module: chatgptWeb | Lines: 1102-1282 (181 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { ChatGptWebExecutor } from "../../../executors/chatgpt-web.ts"; +import { getChatGptImage } from "../../../services/chatgptImageCache.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; + +export const CHATGPT_WEB_IMAGE_MARKDOWN_RE = /!\[[^\]]*\]\(([^)\s]+)\)/g; +export const CHATGPT_WEB_IMAGE_ID_RE = + /\/v1\/chatgpt-web\/image\/([a-f0-9]{16,64})(?=[?\s"'<>)]|$)/i; + +export 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; +} + +export 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"); +} + +export 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, + }); +} diff --git a/open-sse/handlers/imageGeneration/providers/comfyUI.ts b/open-sse/handlers/imageGeneration/providers/comfyUI.ts new file mode 100644 index 0000000000..4540c52a0c --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/comfyUI.ts @@ -0,0 +1,116 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: comfyui | Module: comfyUI | Lines: 3213-3314 (102 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { randomUUID } from "crypto"; +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { + submitComfyWorkflow, + pollComfyResult, + fetchComfyOutput, + extractComfyOutputFiles, +} from "../../../utils/comfyuiClient.ts"; + +export 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: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/haiper.ts b/open-sse/handlers/imageGeneration/providers/haiper.ts new file mode 100644 index 0000000000..f22ad35cd8 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/haiper.ts @@ -0,0 +1,120 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: haiper | Module: haiper | Lines: 3315-3426 (112 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sleep } from "../../../utils/sleep.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export 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: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/hyperbolic.ts b/open-sse/handlers/imageGeneration/providers/hyperbolic.ts new file mode 100644 index 0000000000..528695c30d --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/hyperbolic.ts @@ -0,0 +1,100 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: hyperbolic | Module: hyperbolic | Lines: 2661-2758 (98 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export async function handleHyperbolicImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + + const [width, height] = (body.size || "1024x1024").split("x").map(Number); + + const upstreamBody = { + model_name: model, + prompt: body.prompt, + height: height || 1024, + width: width || 1024, + backend: "auto", + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (hyperbolic) | prompt: "${promptPreview}..."`); + } + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + + return { success: false, status: response.status, error: errorText }; + } + + const data = await response.json(); + // Transform { images: [{ image: base64 }] } → OpenAI format + const images = (data.images || []).map((img) => ({ + b64_json: img.image, + revised_prompt: body.prompt, + })); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} diff --git a/open-sse/handlers/imageGeneration/providers/ideogram.ts b/open-sse/handlers/imageGeneration/providers/ideogram.ts new file mode 100644 index 0000000000..824ca45385 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/ideogram.ts @@ -0,0 +1,96 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: ideogram | Module: ideogram | Lines: 3559-3669 (111 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export 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: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} diff --git a/open-sse/handlers/imageGeneration/providers/imagen3.ts b/open-sse/handlers/imageGeneration/providers/imagen3.ts new file mode 100644 index 0000000000..c53a0016c2 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/imagen3.ts @@ -0,0 +1,136 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: imagen3 | Module: imagen3 | Lines: 3670-3777 (108 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { mapImageSize } from "../../../translator/image/sizeMapper.ts"; + +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 + */ +export 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) => ({ + 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}` }; + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/leonardo.ts b/open-sse/handlers/imageGeneration/providers/leonardo.ts new file mode 100644 index 0000000000..c407693f46 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/leonardo.ts @@ -0,0 +1,140 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: leonardo | Module: leonardo | Lines: 3427-3558 (132 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sleep } from "../../../utils/sleep.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export 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: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/sdWebUI.ts b/open-sse/handlers/imageGeneration/providers/sdWebUI.ts new file mode 100644 index 0000000000..aa4576ef22 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/sdWebUI.ts @@ -0,0 +1,94 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: sd-webui | Module: sdWebUI | Lines: 3121-3212 (92 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export async function handleSDWebUIImageGeneration({ model, provider, providerConfig, body, log }) { + const startTime = Date.now(); + const [width, height] = (body.size || "512x512").split("x").map(Number); + + const upstreamBody = { + prompt: body.prompt, + negative_prompt: body.negative_prompt || "", + width: width || 512, + height: height || 512, + steps: body.steps || 20, + cfg_scale: body.cfg_scale || 7, + sampler_name: body.sampler || "Euler a", + batch_size: body.n || 1, + override_settings: { + sd_model_checkpoint: model, + }, + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (sdwebui) | prompt: "${promptPreview}..."`); + } + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + + return { success: false, status: response.status, error: errorText }; + } + + const data = await response.json(); + // SD WebUI returns { images: ["base64...", ...] } + const images = (data.images || []).map((b64) => ({ + b64_json: b64, + revised_prompt: body.prompt, + })); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} sdwebui error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +}