|
| 1 | + |
| 2 | +/** |
| 3 | + * Image Generation Handler |
| 4 | + * |
| 5 | + * Handles POST /v1/images/generations requests. |
| 6 | + * Proxies to upstream image generation providers using OpenAI-compatible format. |
| 7 | + * |
| 8 | + * Request format (OpenAI-compatible): |
| 9 | + * { |
| 10 | + * "model": "openai/gpt-image-2", |
| 11 | + * "prompt": "a beautiful sunset over mountains", |
| 12 | + * "n": 1, |
| 13 | + * "size": "1024x1024", |
| 14 | + * "quality": "standard", // optional: "standard" | "hd" |
| 15 | + * "response_format": "url" // optional: "url" | "b64_json" |
| 16 | + * } |
| 17 | + */ |
| 18 | + |
| 19 | +import { getImageProvider, parseImageModel } from "../../config/imageRegistry.ts"; |
| 20 | + |
| 21 | +import { mapImageSize } from "../../translator/image/sizeMapper.ts"; |
| 22 | + |
| 23 | +import { sleep } from "../../utils/sleep.ts"; |
| 24 | + |
| 25 | +import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../../utils/error.ts"; |
| 26 | + |
| 27 | +import { extractImageInputs, normalizeRequestedImageFormat, resolveImageSource, isHttpUrl, parseSizeToDimensions, normalizeProviderImagePayload, normalizePositiveNumber } from "./utils.ts"; |
| 28 | +import { saveImageErrorResult, saveImageSuccessResult } from "./logging.ts"; |
| 29 | + |
| 30 | + |
| 31 | + |
| 32 | +const BFL_MODEL_ENDPOINTS = { |
| 33 | + "flux-2-max": "/v1/flux-2-max", |
| 34 | + "flux-2-pro": "/v1/flux-2-pro", |
| 35 | + "flux-2-flex": "/v1/flux-2-flex", |
| 36 | + "flux-2-klein-9b": "/v1/flux-2-klein-9b", |
| 37 | + "flux-2-klein-4b": "/v1/flux-2-klein-4b", |
| 38 | + "flux-kontext-pro": "/v1/flux-kontext-pro", |
| 39 | + "flux-kontext-max": "/v1/flux-kontext-max", |
| 40 | + "flux-pro-1.1": "/v1/flux-pro-1.1", |
| 41 | + "flux-pro-1.1-ultra": "/v1/flux-pro-1.1-ultra", |
| 42 | + "flux-dev": "/v1/flux-dev", |
| 43 | + "flux-pro": "/v1/flux-pro", |
| 44 | +}; |
| 45 | + |
| 46 | + |
| 47 | + |
| 48 | +const BFL_EDIT_MODELS = new Set([ |
| 49 | + "flux-2-max", |
| 50 | + "flux-2-pro", |
| 51 | + "flux-2-flex", |
| 52 | + "flux-kontext-pro", |
| 53 | + "flux-kontext-max", |
| 54 | +]); |
| 55 | + |
| 56 | + |
| 57 | + |
| 58 | +const BFL_FAILURE_STATUSES = new Set(["Error", "Failed", "Content Moderated", "Request Moderated"]); |
| 59 | + |
| 60 | + |
| 61 | + |
| 62 | +export async function handleBlackForestLabsImageGeneration({ |
| 63 | + model, |
| 64 | + provider, |
| 65 | + providerConfig, |
| 66 | + body, |
| 67 | + credentials, |
| 68 | + log, |
| 69 | +}) { |
| 70 | + const startTime = Date.now(); |
| 71 | + const token = credentials?.apiKey || credentials?.accessToken; |
| 72 | + const endpoint = BFL_MODEL_ENDPOINTS[model]; |
| 73 | + |
| 74 | + if (!endpoint) { |
| 75 | + return { |
| 76 | + success: false, |
| 77 | + status: 400, |
| 78 | + error: `Unsupported Black Forest Labs image model: ${model}`, |
| 79 | + }; |
| 80 | + } |
| 81 | + |
| 82 | + const { imageUrl, maskUrl } = extractImageInputs(body); |
| 83 | + const upstreamBody: Record<string, unknown> = { |
| 84 | + prompt: body.prompt, |
| 85 | + output_format: normalizeRequestedImageFormat(body, "png"), |
| 86 | + }; |
| 87 | + |
| 88 | + try { |
| 89 | + if (BFL_EDIT_MODELS.has(model) && imageUrl) { |
| 90 | + upstreamBody.input_image = (await resolveImageSource(imageUrl)).base64; |
| 91 | + } else if (imageUrl && isHttpUrl(imageUrl)) { |
| 92 | + upstreamBody.image_url = imageUrl; |
| 93 | + } |
| 94 | + |
| 95 | + if (maskUrl && (model === "flux-pro-1.0-fill" || model === "flux-kontext-pro")) { |
| 96 | + upstreamBody.mask = (await resolveImageSource(maskUrl)).base64; |
| 97 | + } |
| 98 | + |
| 99 | + if (model === "flux-kontext-pro" || model === "flux-kontext-max") { |
| 100 | + upstreamBody.aspect_ratio = body.aspect_ratio || mapImageSize(body.size); |
| 101 | + } else if (typeof body.size === "string" && body.size.includes("x")) { |
| 102 | + const { width, height } = parseSizeToDimensions(body.size, 1024); |
| 103 | + upstreamBody.width = width; |
| 104 | + upstreamBody.height = height; |
| 105 | + } |
| 106 | + |
| 107 | + if (body.seed !== undefined) upstreamBody.seed = body.seed; |
| 108 | + if (body.n !== undefined && model.includes("ultra")) |
| 109 | + upstreamBody.num_images = Number(body.n) || 1; |
| 110 | + if (body.quality === "hd" && model.includes("ultra")) upstreamBody.raw = true; |
| 111 | + if (body.left !== undefined) upstreamBody.left = body.left; |
| 112 | + if (body.right !== undefined) upstreamBody.right = body.right; |
| 113 | + if (body.top !== undefined) upstreamBody.top = body.top; |
| 114 | + if (body.bottom !== undefined) upstreamBody.bottom = body.bottom; |
| 115 | + if (body.steps !== undefined) upstreamBody.steps = body.steps; |
| 116 | + if (body.guidance !== undefined) upstreamBody.guidance = body.guidance; |
| 117 | + if (body.grow_mask !== undefined) upstreamBody.grow_mask = body.grow_mask; |
| 118 | + if (body.safety_tolerance !== undefined) upstreamBody.safety_tolerance = body.safety_tolerance; |
| 119 | + |
| 120 | + if (log) { |
| 121 | + const promptPreview = String(body.prompt ?? "").slice(0, 60); |
| 122 | + log.info("IMAGE", `${provider}/${model} (black-forest-labs) | prompt: "${promptPreview}..."`); |
| 123 | + } |
| 124 | + |
| 125 | + const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, { |
| 126 | + method: "POST", |
| 127 | + headers: { |
| 128 | + "Content-Type": "application/json", |
| 129 | + Accept: "application/json", |
| 130 | + "x-key": token, |
| 131 | + }, |
| 132 | + body: JSON.stringify(upstreamBody), |
| 133 | + }); |
| 134 | + |
| 135 | + if (!response.ok) { |
| 136 | + const errorText = await response.text(); |
| 137 | + if (log) |
| 138 | + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); |
| 139 | + return saveImageErrorResult({ |
| 140 | + provider, |
| 141 | + model, |
| 142 | + status: response.status, |
| 143 | + startTime, |
| 144 | + error: errorText, |
| 145 | + requestBody: upstreamBody, |
| 146 | + }); |
| 147 | + } |
| 148 | + |
| 149 | + const initialPayload = await response.json(); |
| 150 | + const finalPayload = initialPayload?.polling_url |
| 151 | + ? await pollBlackForestLabsResult({ |
| 152 | + pollingUrl: initialPayload?.polling_url, |
| 153 | + token, |
| 154 | + body, |
| 155 | + log, |
| 156 | + }) |
| 157 | + : initialPayload; |
| 158 | + |
| 159 | + const images = await normalizeProviderImagePayload(finalPayload, body, log); |
| 160 | + return saveImageSuccessResult({ |
| 161 | + provider, |
| 162 | + model, |
| 163 | + startTime, |
| 164 | + requestBody: upstreamBody, |
| 165 | + responseBody: { images_count: images.length }, |
| 166 | + created: finalPayload.created, |
| 167 | + images, |
| 168 | + }); |
| 169 | + } catch (err) { |
| 170 | + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); |
| 171 | + return saveImageErrorResult({ |
| 172 | + provider, |
| 173 | + model, |
| 174 | + status: 502, |
| 175 | + startTime, |
| 176 | + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, |
| 177 | + }); |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | + |
| 182 | + |
| 183 | +async function pollBlackForestLabsResult({ pollingUrl, token, body, log }) { |
| 184 | + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300000); |
| 185 | + const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, 1500); |
| 186 | + const deadline = Date.now() + timeoutMs; |
| 187 | + |
| 188 | + while (Date.now() < deadline) { |
| 189 | + const response = await fetch(pollingUrl, { |
| 190 | + method: "GET", |
| 191 | + headers: { |
| 192 | + "x-key": token, |
| 193 | + }, |
| 194 | + }); |
| 195 | + |
| 196 | + if (!response.ok) { |
| 197 | + const errorText = await response.text(); |
| 198 | + throw new Error(`BFL polling failed (${response.status}): ${errorText}`); |
| 199 | + } |
| 200 | + |
| 201 | + const payload = await response.json(); |
| 202 | + const status = payload?.status; |
| 203 | + |
| 204 | + if (status === "Ready") { |
| 205 | + return payload; |
| 206 | + } |
| 207 | + |
| 208 | + if (BFL_FAILURE_STATUSES.has(status)) { |
| 209 | + throw new Error(`BFL image generation failed: ${status}`); |
| 210 | + } |
| 211 | + |
| 212 | + if (log) { |
| 213 | + log.info("IMAGE", `black-forest-labs polling status: ${String(status || "Pending")}`); |
| 214 | + } |
| 215 | + |
| 216 | + await sleep(pollIntervalMs); |
| 217 | + } |
| 218 | + |
| 219 | + throw new Error(`BFL polling timed out after ${timeoutMs}ms`); |
| 220 | +} |
| 221 | + |
0 commit comments