Add Cloudflare Workers AI image generation (#973)
This commit is contained in:
@@ -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"] },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user