mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Add Cloudflare Workers AI image generation (#973)
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 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) {
|
||||
|
||||
@@ -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 }) {
|
||||
<input
|
||||
value={refImage}
|
||||
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"
|
||||
/>
|
||||
{refImage && (
|
||||
@@ -1199,9 +1228,9 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{refImage.trim() && (
|
||||
{refImagePreviewSrc && (
|
||||
<img
|
||||
src={refImage.trim()}
|
||||
src={refImagePreviewSrc}
|
||||
alt="Reference"
|
||||
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
@@ -1212,6 +1241,39 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
</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 */}
|
||||
{(exConfig.extraFields || [])
|
||||
.filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key)))
|
||||
|
||||
@@ -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"] },
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user