Add Cloudflare Workers AI image generation (#973)

This commit is contained in:
Aleksei
2026-05-09 09:53:39 +07:00
committed by GitHub
parent dd15d162fc
commit 787d248030
7 changed files with 433 additions and 18 deletions
+11
View File
@@ -393,6 +393,17 @@ export const PROVIDER_MODELS = {
{ id: "@cf/zai-org/glm-4.7-flash", name: "GLM 4.7 Flash" }, { id: "@cf/zai-org/glm-4.7-flash", name: "GLM 4.7 Flash" },
{ id: "@cf/qwen/qwq-32b", name: "QwQ 32B" }, { 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/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: [ byteplus: [
{ id: "seed-2-0-pro-260328", name: "Seed 2.0 Pro" }, { id: "seed-2-0-pro-260328", name: "Seed 2.0 Pro" },
+25 -6
View File
@@ -5,6 +5,12 @@ import { getExecutor } from "../executors/index.js";
import { getImageAdapter } from "./imageProviders/index.js"; import { getImageAdapter } from "./imageProviders/index.js";
import { urlToBase64 } from "./imageProviders/_base.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. * Core image generation handler — orchestrator only.
* Provider-specific URL/headers/body/parse/normalize live in `./imageProviders/{id}.js`. * 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); let url;
const headers = adapter.buildHeaders(credentials); let headers;
const requestBody = adapter.buildBody(model, body); 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)}..."`); log?.debug?.("IMAGE", `${provider.toUpperCase()} | ${model} | prompt="${body.prompt.slice(0, 50)}..."`);
@@ -55,7 +69,7 @@ export async function handleImageGenerationCore({
providerResponse = await fetch(url, { providerResponse = await fetch(url, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify(requestBody), body: serializeRequestBody(requestBody),
}); });
} catch (error) { } catch (error) {
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
@@ -83,12 +97,13 @@ export async function handleImageGenerationCore({
if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials); if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials);
try { 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); const retryUrl = adapter.buildUrl(model, credentials);
providerResponse = await fetch(retryUrl, { providerResponse = await fetch(retryUrl, {
method: "POST", method: "POST",
headers: retryHeaders, headers: retryHeaders,
body: JSON.stringify(requestBody), body: serializeRequestBody(retryBody),
}); });
} catch { } catch {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`);
@@ -114,6 +129,10 @@ export async function handleImageGenerationCore({
log, log,
streamToClient, streamToClient,
onRequestSuccess, onRequestSuccess,
url,
requestBody,
model,
body,
}); });
// Codex streaming case: returns an SSE Response directly // Codex streaming case: returns an SSE Response directly
if (parsed?.sseResponse) { if (parsed?.sseResponse) {
@@ -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,
};
@@ -10,6 +10,7 @@ import falAi from "./falAi.js";
import stabilityAi from "./stabilityAi.js"; import stabilityAi from "./stabilityAi.js";
import blackForestLabs from "./blackForestLabs.js"; import blackForestLabs from "./blackForestLabs.js";
import runwayml from "./runwayml.js"; import runwayml from "./runwayml.js";
import cloudflareAi from "./cloudflareAi.js";
const ADAPTERS = { const ADAPTERS = {
openai: createOpenAIAdapter("openai"), openai: createOpenAIAdapter("openai"),
@@ -26,6 +27,7 @@ const ADAPTERS = {
"stability-ai": stabilityAi, "stability-ai": stabilityAi,
"black-forest-labs": blackForestLabs, "black-forest-labs": blackForestLabs,
runwayml, runwayml,
"cloudflare-ai": cloudflareAi,
}; };
export function getImageAdapter(provider) { export function getImageAdapter(provider) {
@@ -42,6 +42,27 @@ const DEFAULT_RESPONSE_EXAMPLE = `{
"usage": { "prompt_tokens": 9, "total_tokens": 9 } "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 // Config-driven example defaults per kind
const KIND_EXAMPLE_CONFIG = { const KIND_EXAMPLE_CONFIG = {
webSearch: { webSearch: {
@@ -909,9 +930,11 @@ function GenericExampleCard({ providerId, kind }) {
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? ""); const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
const selectedModelObj = kindModels.find((m) => m.id === selectedModel); const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit"); const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
const supportsMask = !!selectedModelObj?.capabilities?.includes("mask");
const [input, setInput] = useState(safeExConfig.defaultInput || ""); const [input, setInput] = useState(safeExConfig.defaultInput || "");
const [refImage, setRefImage] = useState(""); const [refImage, setRefImage] = useState("");
const [maskImage, setMaskImage] = useState("");
const [extraValues, setExtraValues] = useState(() => const [extraValues, setExtraValues] = useState(() =>
(safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {}) (safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
); );
@@ -960,6 +983,11 @@ function GenericExampleCard({ providerId, kind }) {
const modelFull = !needsModel const modelFull = !needsModel
? providerAlias ? providerAlias
: (selectedModel ? `${providerAlias}/${selectedModel}` : (allowManualModel ? "" : 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) // Build request body with optional extra fields (only non-empty values)
const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => { const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
@@ -973,7 +1001,8 @@ function GenericExampleCard({ providerId, kind }) {
[exConfig.bodyKey]: input, [exConfig.bodyKey]: input,
...exConfig.extraBody, ...exConfig.extraBody,
...extraBodyFromFields, ...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 // Streaming supported for codex image (Plus/Pro accounts) — disabled when binary output requested
@@ -1186,7 +1215,7 @@ function GenericExampleCard({ providerId, kind }) {
<input <input
value={refImage} value={refImage}
onChange={(e) => setRefImage(e.target.value)} onChange={(e) => 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" 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 && ( {refImage && (
@@ -1199,9 +1228,9 @@ function GenericExampleCard({ providerId, kind }) {
</button> </button>
)} )}
</div> </div>
{refImage.trim() && ( {refImagePreviewSrc && (
<img <img
src={refImage.trim()} src={refImagePreviewSrc}
alt="Reference" alt="Reference"
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar" className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
onError={(e) => { e.currentTarget.style.display = "none"; }} onError={(e) => { e.currentTarget.style.display = "none"; }}
@@ -1212,6 +1241,39 @@ function GenericExampleCard({ providerId, kind }) {
</Row> </Row>
)} )}
{supportsMask && (
<Row label="Mask (URL)">
<div className="flex flex-col gap-2">
<div className="relative">
<input
value={maskImage}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setMaskImage("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
{maskImagePreviewSrc && (
<img
src={maskImagePreviewSrc}
alt="Mask"
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
onError={(e) => { e.currentTarget.style.display = "none"; }}
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
/>
)}
</div>
</Row>
)}
{/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */} {/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */}
{(exConfig.extraFields || []) {(exConfig.extraFields || [])
.filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key))) .filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key)))
+1 -1
View File
@@ -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" } }, 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" } }, 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" }] } }, 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"] }, 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"] },
}; };
+150 -7
View File
@@ -21,6 +21,7 @@ describe("handleImageGenerationCore", () => {
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
vi.useRealTimers();
}); });
it("validates required prompt field", async () => { it("validates required prompt field", async () => {
@@ -156,29 +157,54 @@ describe("handleImageGenerationCore", () => {
}); });
it("generates image with NanoBanana format", async () => { it("generates image with NanoBanana format", async () => {
global.fetch.mockResolvedValueOnce( vi.useFakeTimers();
new Response( global.fetch
JSON.stringify({ image: "base64nanobanana" }), .mockResolvedValueOnce(
{ status: 200, headers: { "Content-Type": "application/json" } } 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" }, body: { prompt: "A robot", n: 2, size: "1024x1792" },
modelInfo: { provider: "nanobanana", model: "nanobanana-flash" }, modelInfo: { provider: "nanobanana", model: "nanobanana-flash" },
credentials: { apiKey: "test-key" }, credentials: { apiKey: "test-key" },
log: null, log: null,
}); });
await vi.advanceTimersByTimeAsync(1500);
const result = await pending;
expect(result.success).toBe(true); expect(result.success).toBe(true);
const fetchCall = global.fetch.mock.calls[0]; const fetchCall = global.fetch.mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body); const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.type).toBe("TEXTTOIAMGE"); expect(requestBody.type).toBe("TEXTTOIAMGE");
expect(requestBody.numImages).toBe(2); expect(requestBody.numImages).toBe(2);
expect(requestBody.image_size).toBe("9:16"); 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(); 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 () => { it("generates image with SD WebUI format", async () => {
@@ -258,6 +284,123 @@ describe("handleImageGenerationCore", () => {
expect(responseBody.data[0].b64_json).toBeTruthy(); 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 () => { it("handles provider error responses", async () => {
global.fetch.mockResolvedValueOnce( global.fetch.mockResolvedValueOnce(
new Response( new Response(