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/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" },
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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"] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user