From 787d248030cc0d21a55a9691b09fc5ba04e85b31 Mon Sep 17 00:00:00 2001 From: Aleksei <54264559+eng2007@users.noreply.github.com> Date: Sat, 9 May 2026 05:53:39 +0300 Subject: [PATCH] Add Cloudflare Workers AI image generation (#973) --- open-sse/config/providerModels.js | 11 ++ open-sse/handlers/imageGenerationCore.js | 31 ++- .../handlers/imageProviders/cloudflareAi.js | 178 ++++++++++++++++++ open-sse/handlers/imageProviders/index.js | 2 + .../media-providers/[kind]/[id]/page.js | 70 ++++++- src/shared/constants/providers.js | 2 +- tests/unit/image-generation.test.js | 157 ++++++++++++++- 7 files changed, 433 insertions(+), 18 deletions(-) create mode 100644 open-sse/handlers/imageProviders/cloudflareAi.js diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 5b91396e..5a8d2f26 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -393,6 +393,17 @@ export const PROVIDER_MODELS = { { id: "@cf/zai-org/glm-4.7-flash", name: "GLM 4.7 Flash" }, { id: "@cf/qwen/qwq-32b", name: "QwQ 32B" }, { id: "@cf/qwen/qwen2.5-coder-32b-instruct", name: "Qwen 2.5 Coder 32B Instruct" }, + { id: "@cf/black-forest-labs/flux-2-klein-9b", name: "FLUX.2 Klein 9B", type: "image", params: ["size"] }, + { id: "@cf/black-forest-labs/flux-2-klein-4b", name: "FLUX.2 Klein 4B", type: "image", params: ["size"] }, + { id: "@cf/black-forest-labs/flux-2-dev", name: "FLUX.2 Dev", type: "image", params: ["size"] }, + { id: "@cf/leonardo/lucid-origin", name: "Lucid Origin", type: "image", params: ["size"] }, + { id: "@cf/leonardo/phoenix-1.0", name: "Phoenix 1.0", type: "image", params: ["size"] }, + { id: "@cf/black-forest-labs/flux-1-schnell", name: "FLUX.1 Schnell", type: "image", params: ["size"] }, + { id: "@cf/bytedance/stable-diffusion-xl-lightning", name: "SDXL Lightning", type: "image", params: ["size"] }, + { id: "@cf/lykon/dreamshaper-8-lcm", name: "DreamShaper 8 LCM", type: "image", params: ["size"] }, + { id: "@cf/runwayml/stable-diffusion-v1-5-img2img", name: "Stable Diffusion v1.5 Img2Img", type: "image", params: ["size"], capabilities: ["edit"] }, + { id: "@cf/runwayml/stable-diffusion-v1-5-inpainting", name: "Stable Diffusion v1.5 Inpainting", type: "image", params: ["size"], capabilities: ["edit", "mask"] }, + { id: "@cf/stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", type: "image", params: ["size"] }, ], byteplus: [ { id: "seed-2-0-pro-260328", name: "Seed 2.0 Pro" }, diff --git a/open-sse/handlers/imageGenerationCore.js b/open-sse/handlers/imageGenerationCore.js index 3027f282..280b3158 100644 --- a/open-sse/handlers/imageGenerationCore.js +++ b/open-sse/handlers/imageGenerationCore.js @@ -5,6 +5,12 @@ import { getExecutor } from "../executors/index.js"; import { getImageAdapter } from "./imageProviders/index.js"; import { urlToBase64 } from "./imageProviders/_base.js"; +function serializeRequestBody(requestBody) { + if (typeof FormData !== "undefined" && requestBody instanceof FormData) return requestBody; + if (typeof requestBody === "string") return requestBody; + return JSON.stringify(requestBody); +} + /** * Core image generation handler โ€” orchestrator only. * Provider-specific URL/headers/body/parse/normalize live in `./imageProviders/{id}.js`. @@ -44,9 +50,17 @@ export async function handleImageGenerationCore({ ); } - const url = adapter.buildUrl(model, credentials); - const headers = adapter.buildHeaders(credentials); - const requestBody = adapter.buildBody(model, body); + let url; + let headers; + let requestBody; + + try { + url = adapter.buildUrl(model, credentials); + requestBody = await adapter.buildBody(model, body); + headers = adapter.buildHeaders(credentials, requestBody, model, body); + } catch (error) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, error.message || `Invalid ${provider} image request`); + } log?.debug?.("IMAGE", `${provider.toUpperCase()} | ${model} | prompt="${body.prompt.slice(0, 50)}..."`); @@ -55,7 +69,7 @@ export async function handleImageGenerationCore({ providerResponse = await fetch(url, { method: "POST", headers, - body: JSON.stringify(requestBody), + body: serializeRequestBody(requestBody), }); } catch (error) { const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); @@ -83,12 +97,13 @@ export async function handleImageGenerationCore({ if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials); try { - const retryHeaders = adapter.buildHeaders(credentials); + const retryBody = await adapter.buildBody(model, body); + const retryHeaders = adapter.buildHeaders(credentials, retryBody, model, body); const retryUrl = adapter.buildUrl(model, credentials); providerResponse = await fetch(retryUrl, { method: "POST", headers: retryHeaders, - body: JSON.stringify(requestBody), + body: serializeRequestBody(retryBody), }); } catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); @@ -114,6 +129,10 @@ export async function handleImageGenerationCore({ log, streamToClient, onRequestSuccess, + url, + requestBody, + model, + body, }); // Codex streaming case: returns an SSE Response directly if (parsed?.sseResponse) { diff --git a/open-sse/handlers/imageProviders/cloudflareAi.js b/open-sse/handlers/imageProviders/cloudflareAi.js new file mode 100644 index 00000000..9b0d2ef5 --- /dev/null +++ b/open-sse/handlers/imageProviders/cloudflareAi.js @@ -0,0 +1,178 @@ +import { nowSec, urlToBase64 } from "./_base.js"; + +const BASE_URL = "https://api.cloudflare.com/client/v4/accounts"; + +const MULTIPART_MODELS = new Set([ + "@cf/black-forest-labs/flux-2-dev", + "@cf/black-forest-labs/flux-2-klein-4b", + "@cf/black-forest-labs/flux-2-klein-9b", +]); + +const OPTIONAL_FIELDS = [ + "negative_prompt", + "guidance", + "seed", + "num_steps", + "steps", + "strength", +]; + +function sizeToDimensions(size) { + const match = /^(\d+)x(\d+)$/.exec(String(size || "")); + if (!match) return {}; + return { + width: Number(match[1]), + height: Number(match[2]), + }; +} + +function getDimensions(body) { + return { + ...sizeToDimensions(body.size), + ...(Number.isFinite(Number(body.width)) ? { width: Number(body.width) } : {}), + ...(Number.isFinite(Number(body.height)) ? { height: Number(body.height) } : {}), + }; +} + +async function resolveImageInput(value) { + if (Array.isArray(value)) { + return { bytes: value, b64: Buffer.from(value).toString("base64") }; + } + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^https?:\/\//i.test(trimmed)) { + const b64 = await urlToBase64(trimmed); + return { bytes: base64ToBytes(b64), b64 }; + } + const match = /^data:image\/[^;]+;base64,(.+)$/i.exec(trimmed); + const b64 = match ? match[1] : trimmed; + return { bytes: base64ToBytes(b64), b64 }; +} + +function base64ToBytes(value) { + try { + return Array.from(Buffer.from(value, "base64")); + } catch { + return value; + } +} + +function addOptionalFields(target, body, append) { + for (const key of OPTIONAL_FIELDS) { + const value = body[key]; + if (value === undefined || value === null || value === "") continue; + append(target, key, value); + } +} + +async function buildJsonBody(body) { + const req = { prompt: body.prompt, ...getDimensions(body) }; + + addOptionalFields(req, body, (target, key, value) => { + target[key] = value; + }); + + const imageData = await resolveImageInput(body.image); + if (imageData) { + req.image_b64 = imageData.b64; + req.image = imageData.bytes; + } + + const maskData = await resolveImageInput(body.mask_image || body.maskImage || body.mask); + if (maskData) { + req.mask_b64 = maskData.b64; + req.mask = maskData.bytes; + req.mask_image = maskData.bytes; + } + + return req; +} + +function buildMultipartBody(body) { + const form = new FormData(); + form.append("prompt", body.prompt); + + const dimensions = getDimensions(body); + for (const [key, value] of Object.entries(dimensions)) { + form.append(key, String(value)); + } + + addOptionalFields(form, body, (target, key, value) => { + target.append(key, String(value)); + }); + + return form; +} + +function imageItemFromString(value) { + if (typeof value !== "string" || !value) return null; + if (/^data:image\/[^;]+;base64,/i.test(value)) { + return { b64_json: value.replace(/^data:image\/[^;]+;base64,/i, "") }; + } + if (/^https?:\/\//i.test(value)) return { url: value }; + return { b64_json: value }; +} + +function normalizeCloudflareResponse(responseBody) { + if (responseBody?.created && Array.isArray(responseBody?.data)) return responseBody; + + const result = responseBody?.result ?? responseBody; + const queuedResponse = Array.isArray(result?.responses) + ? result.responses.find((item) => item?.success !== false)?.result + : null; + if (queuedResponse) return normalizeCloudflareResponse(queuedResponse); + + const image = + (typeof result === "string" ? result : null) || + result?.image || + result?.data?.[0]?.b64_json || + result?.data?.[0]?.url; + + const item = imageItemFromString(image); + return { + created: nowSec(), + data: item ? [item] : [], + }; +} + +export default { + buildUrl: (model, creds) => { + const accountId = creds?.providerSpecificData?.accountId; + if (!accountId) throw new Error("cloudflare-ai requires accountId in providerSpecificData"); + return `${BASE_URL}/${accountId}/ai/run/${model}`; + }, + + buildHeaders: (creds, requestBody) => { + const headers = {}; + const isMultipart = typeof FormData !== "undefined" && requestBody instanceof FormData; + if (!isMultipart) { + headers["Content-Type"] = "application/json"; + } + const key = creds?.apiKey || creds?.accessToken; + if (key) headers.Authorization = `Bearer ${key}`; + return headers; + }, + + buildBody: async (model, body) => ( + MULTIPART_MODELS.has(model) + ? buildMultipartBody(body) + : await buildJsonBody(body) + ), + + async parseResponse(response) { + const contentType = (response.headers.get("Content-Type") || "").toLowerCase(); + if (contentType.startsWith("image/")) { + const buf = await response.arrayBuffer(); + return { + created: nowSec(), + data: [{ b64_json: Buffer.from(buf).toString("base64") }], + }; + } + + const json = await response.json(); + return normalizeCloudflareResponse(json); + }, + + normalize: normalizeCloudflareResponse, +}; diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.js index 83dcfc73..3f875432 100644 --- a/open-sse/handlers/imageProviders/index.js +++ b/open-sse/handlers/imageProviders/index.js @@ -10,6 +10,7 @@ import falAi from "./falAi.js"; import stabilityAi from "./stabilityAi.js"; import blackForestLabs from "./blackForestLabs.js"; import runwayml from "./runwayml.js"; +import cloudflareAi from "./cloudflareAi.js"; const ADAPTERS = { openai: createOpenAIAdapter("openai"), @@ -26,6 +27,7 @@ const ADAPTERS = { "stability-ai": stabilityAi, "black-forest-labs": blackForestLabs, runwayml, + "cloudflare-ai": cloudflareAi, }; export function getImageAdapter(provider) { diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index 36605632..c771492b 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -42,6 +42,27 @@ const DEFAULT_RESPONSE_EXAMPLE = `{ "usage": { "prompt_tokens": 9, "total_tokens": 9 } }`; +const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png"; +const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png"; + +function getImageEditDefaults(providerId, modelId) { + if (providerId !== "cloudflare-ai") return {}; + if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") { + return { image: CLOUDFLARE_TEST_IMAGE_URL }; + } + if (modelId === "@cf/runwayml/stable-diffusion-v1-5-inpainting") { + return { image: CLOUDFLARE_TEST_IMAGE_URL, mask_image: CLOUDFLARE_TEST_MASK_URL }; + } + return {}; +} + +function toImagePreviewSrc(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) return ""; + if (/^(data:image\/|https?:\/\/)/i.test(trimmed)) return trimmed; + return `data:image/png;base64,${trimmed}`; +} + // Config-driven example defaults per kind const KIND_EXAMPLE_CONFIG = { webSearch: { @@ -909,9 +930,11 @@ function GenericExampleCard({ providerId, kind }) { const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? ""); const selectedModelObj = kindModels.find((m) => m.id === selectedModel); const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit"); + const supportsMask = !!selectedModelObj?.capabilities?.includes("mask"); const [input, setInput] = useState(safeExConfig.defaultInput || ""); const [refImage, setRefImage] = useState(""); + const [maskImage, setMaskImage] = useState(""); const [extraValues, setExtraValues] = useState(() => (safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {}) ); @@ -960,6 +983,11 @@ function GenericExampleCard({ providerId, kind }) { const modelFull = !needsModel ? providerAlias : (selectedModel ? `${providerAlias}/${selectedModel}` : (allowManualModel ? "" : providerAlias)); + const imageEditDefaults = getImageEditDefaults(providerId, selectedModel); + const effectiveRefImage = refImage.trim() || imageEditDefaults.image || ""; + const effectiveMaskImage = maskImage.trim() || imageEditDefaults.mask_image || ""; + const refImagePreviewSrc = toImagePreviewSrc(effectiveRefImage); + const maskImagePreviewSrc = toImagePreviewSrc(effectiveMaskImage); // Build request body with optional extra fields (only non-empty values) const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => { @@ -973,7 +1001,8 @@ function GenericExampleCard({ providerId, kind }) { [exConfig.bodyKey]: input, ...exConfig.extraBody, ...extraBodyFromFields, - ...(supportsEdit && refImage.trim() ? { image: refImage.trim() } : {}), + ...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}), + ...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}), }; // Streaming supported for codex image (Plus/Pro accounts) โ€” disabled when binary output requested @@ -1186,7 +1215,7 @@ function GenericExampleCard({ providerId, kind }) { setRefImage(e.target.value)} - placeholder="https://example.com/source.png" + placeholder={imageEditDefaults.image || "https://example.com/source.png"} className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" /> {refImage && ( @@ -1199,9 +1228,9 @@ function GenericExampleCard({ providerId, kind }) { )} - {refImage.trim() && ( + {refImagePreviewSrc && ( Reference { e.currentTarget.style.display = "none"; }} @@ -1212,6 +1241,39 @@ function GenericExampleCard({ providerId, kind }) { )} + {supportsMask && ( + +
+
+ setMaskImage(e.target.value)} + placeholder={imageEditDefaults.mask_image || "https://example.com/mask.png"} + className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + {maskImage && ( + + )} +
+ {maskImagePreviewSrc && ( + Mask { e.currentTarget.style.display = "none"; }} + onLoad={(e) => { e.currentTarget.style.display = "block"; }} + /> + )} +
+
+ )} + {/* Extra fields โ€” for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */} {(exConfig.extraFields || []) .filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key))) diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index e7285d4b..4b148e7b 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -19,7 +19,7 @@ export const FREE_TIER_PROVIDERS = { ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com", notice: { text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo ยท Max $100/mo.", apiKeyUrl: "https://ollama.com/settings/keys" } }, vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai", notice: { text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } }, gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", mediaPriority: 1, website: "https://ai.google.dev", notice: { apiKeyUrl: "https://aistudio.google.com/app/apikey" }, serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch", "tts", "stt"], sttConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key", format: "gemini-stt", models: [{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Best)" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)" }, { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }] }, searchViaChat: { defaultModel: "gemini-2.5-flash", pricingUrl: "https://ai.google.dev/pricing", freeTier: "Free tier: 15 RPM, 1M tokens/day on gemini-2.5-flash via AI Studio." }, embeddingConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key", models: [{ id: "text-embedding-004", name: "Text Embedding 004", dimensions: 768 }, { id: "embedding-001", name: "Embedding 001", dimensions: 768 }] }, ttsConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key", format: "gemini-tts", models: [{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS" }, { id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS" }] } }, - "cloudflare-ai": { id: "cloudflare-ai", alias: "cf", name: "Cloudflare", icon: "cloud", color: "#F38020", textIcon: "CF", website: "https://developers.cloudflare.com/workers-ai/", notice: { text: "Workers AI free tier. Requires a Cloudflare API token and Account ID.", apiKeyUrl: "https://dash.cloudflare.com/profile/api-tokens" }, serviceKinds: ["llm"], hasProviderSpecificData: true }, + "cloudflare-ai": { id: "cloudflare-ai", alias: "cf", name: "Cloudflare", icon: "cloud", color: "#F38020", textIcon: "CF", website: "https://developers.cloudflare.com/workers-ai/", notice: { text: "Workers AI free tier. Requires a Cloudflare API token and Account ID.", apiKeyUrl: "https://dash.cloudflare.com/profile/api-tokens" }, serviceKinds: ["llm", "image"], hasProviderSpecificData: true }, byteplus: { id: "byteplus", alias: "bpm", name: "BytePlus ModelArk", icon: "cloud", color: "#2563EB", textIcon: "BP", website: "https://console.byteplus.com/ark", notice: { text: "Free credits for new accounts. Access to Seed 2.0, Kimi K2 Thinking, GLM 4.7, GPT-OSS-120B models.", apiKeyUrl: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey" }, serviceKinds: ["llm"] }, }; diff --git a/tests/unit/image-generation.test.js b/tests/unit/image-generation.test.js index fe40ac03..614a28e2 100644 --- a/tests/unit/image-generation.test.js +++ b/tests/unit/image-generation.test.js @@ -21,6 +21,7 @@ describe("handleImageGenerationCore", () => { afterEach(() => { global.fetch = originalFetch; + vi.useRealTimers(); }); it("validates required prompt field", async () => { @@ -156,29 +157,54 @@ describe("handleImageGenerationCore", () => { }); it("generates image with NanoBanana format", async () => { - global.fetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ image: "base64nanobanana" }), - { status: 200, headers: { "Content-Type": "application/json" } } + vi.useFakeTimers(); + global.fetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ code: 200, data: { taskId: "task-123" } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) ) - ); + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + successFlag: 1, + response: { resultImageUrl: "https://example.com/nanobanana.png" }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); - const result = await handleImageGenerationCore({ + const pending = handleImageGenerationCore({ body: { prompt: "A robot", n: 2, size: "1024x1792" }, modelInfo: { provider: "nanobanana", model: "nanobanana-flash" }, credentials: { apiKey: "test-key" }, log: null, }); + await vi.advanceTimersByTimeAsync(1500); + const result = await pending; + expect(result.success).toBe(true); const fetchCall = global.fetch.mock.calls[0]; const requestBody = JSON.parse(fetchCall[1].body); expect(requestBody.type).toBe("TEXTTOIAMGE"); expect(requestBody.numImages).toBe(2); expect(requestBody.image_size).toBe("9:16"); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info?taskId=task-123", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-key", + }), + }) + ); const responseBody = await result.response.json(); - expect(responseBody.data[0].b64_json).toBe("base64nanobanana"); + expect(responseBody.data[0].url).toBe("https://example.com/nanobanana.png"); }); it("generates image with SD WebUI format", async () => { @@ -258,6 +284,123 @@ describe("handleImageGenerationCore", () => { expect(responseBody.data[0].b64_json).toBeTruthy(); }); + it("generates image with Cloudflare Workers AI JSON response", async () => { + global.fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { image: "base64cloudflare" }, + success: true, + errors: [], + messages: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const result = await handleImageGenerationCore({ + body: { prompt: "A lighthouse", size: "1024x1536" }, + modelInfo: { provider: "cloudflare-ai", model: "@cf/leonardo/lucid-origin" }, + credentials: { + apiKey: "cf-token", + providerSpecificData: { accountId: "cf-account" }, + }, + log: null, + }); + + expect(result.success).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.cloudflare.com/client/v4/accounts/cf-account/ai/run/@cf/leonardo/lucid-origin", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer cf-token", + }), + }) + ); + + const fetchCall = global.fetch.mock.calls[0]; + const requestBody = JSON.parse(fetchCall[1].body); + expect(requestBody.prompt).toBe("A lighthouse"); + expect(requestBody.width).toBe(1024); + expect(requestBody.height).toBe(1536); + + const responseBody = await result.response.json(); + expect(responseBody.data[0].b64_json).toBe("base64cloudflare"); + }); + + it("uses multipart form data for Cloudflare FLUX.2 models", async () => { + global.fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { image: "base64flux2" }, + success: true, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const result = await handleImageGenerationCore({ + body: { prompt: "A mountain lake", size: "1792x1024", steps: 4 }, + modelInfo: { provider: "cloudflare-ai", model: "@cf/black-forest-labs/flux-2-klein-9b" }, + credentials: { + apiKey: "cf-token", + providerSpecificData: { accountId: "cf-account" }, + }, + log: null, + }); + + expect(result.success).toBe(true); + + const fetchCall = global.fetch.mock.calls[0]; + expect(fetchCall[1].headers).not.toHaveProperty("Content-Type"); + expect(fetchCall[1].body).toBeInstanceOf(FormData); + expect(fetchCall[1].body.get("prompt")).toBe("A mountain lake"); + expect(fetchCall[1].body.get("width")).toBe("1792"); + expect(fetchCall[1].body.get("height")).toBe("1024"); + expect(fetchCall[1].body.get("steps")).toBe("4"); + }); + + it("resolves Cloudflare img2img and inpainting URL inputs before sending", async () => { + global.fetch + .mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { "Content-Type": "image/png" } })) + .mockResolvedValueOnce(new Response(new Uint8Array([4, 5, 6]), { status: 200, headers: { "Content-Type": "image/png" } })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ result: { image: "base64inpaint" }, success: true }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const result = await handleImageGenerationCore({ + body: { + prompt: "Change to a lion", + image: "https://example.com/source.png", + mask_image: "https://example.com/mask.png", + size: "512x512", + }, + modelInfo: { provider: "cloudflare-ai", model: "@cf/runwayml/stable-diffusion-v1-5-inpainting" }, + credentials: { + apiKey: "cf-token", + providerSpecificData: { accountId: "cf-account" }, + }, + log: null, + }); + + expect(result.success).toBe(true); + expect(global.fetch).toHaveBeenNthCalledWith(1, "https://example.com/source.png"); + expect(global.fetch).toHaveBeenNthCalledWith(2, "https://example.com/mask.png"); + + const providerCall = global.fetch.mock.calls[2]; + expect(providerCall[0]).toBe("https://api.cloudflare.com/client/v4/accounts/cf-account/ai/run/@cf/runwayml/stable-diffusion-v1-5-inpainting"); + const requestBody = JSON.parse(providerCall[1].body); + expect(requestBody.image).toEqual([1, 2, 3]); + expect(requestBody.image_b64).toBe(Buffer.from([1, 2, 3]).toString("base64")); + expect(requestBody.mask).toEqual([4, 5, 6]); + expect(requestBody.mask_image).toEqual([4, 5, 6]); + expect(requestBody.mask_b64).toBe(Buffer.from([4, 5, 6]).toString("base64")); + }); + it("handles provider error responses", async () => { global.fetch.mockResolvedValueOnce( new Response(