// Cloudflare Workers AI provider — https://developers.cloudflare.com/workers-ai/ // // Cloudflare exposes two surfaces: // // 1. OpenAI-compatible chat/completions // POST https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/chat/completions // (the account id lives in the URL path, so it is injected at request time) // -> delegated to pi's built-in openai-completions core. // // 2. "Run a model" microservices (image / TTS / ASR) // POST https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_id} // -> handled by custom native streamers below, because their request/response // shapes are NOT chat completions. // // Auth: CLOUDFLARE_API_KEY (Bearer) + CLOUDFLARE_ACCOUNT_ID (URL path). // Free tier: 10,000 neurons/day (UTC reset). The 5 frontier paid models // (deepseek-v4-flash/pro, glm-5.2, kimi-k2.6/k2.7-code) require billing and // are intentionally NOT registered here. // // Modeled on pi-nvidia (clean structure, delegates chat to pi-ai's // openai-completions core) and on pi-cn-free-model-providers (the verified // Cloudflare routing + native image/audio/transcription transports). import { execFile } from "node:child_process"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; import { Image, Markdown } from "@earendil-works/pi-tui"; import { getMarkdownTheme } from "@earendil-works/pi-coding-agent"; const ACCOUNT_ID_ENV = "CLOUDFLARE_ACCOUNT_ID"; const API_KEY_ENV = "CLOUDFLARE_API_KEY"; const RUN_HOST = "https://api.cloudflare.com/client/v4/accounts"; // pi-ai exports openAICompletionsApi both as a named export and (preferred) // as a lazy wrapper. Resolve whichever is present so the extension loads on // any pi-ai build. The function form returns the stream API object. const openAICompletionsApi = await (async () => { try { return (await import("@earendil-works/pi-ai/api/openai-completions.lazy")).openAICompletionsApi; } catch { return (await import("@earendil-works/pi-ai")).openAICompletionsApi; } })(); // ── Clickable file links ────────────────────────────────────────────────── // The TUI renders `[label](url)` as a clickable OSC 8 hyperlink, so generated // media opens in one click instead of copy-pasting the raw path. function fileLink(p: string, label = p): string { return `[${label}](${pathToFileURL(String(p)).href})`; } let appendCloudflareImage: ((image: { path: string; mimeType: string }) => void) | null = null; // --------------------------------------------------------------------------- // Output factory (shared by the native streamers) // --------------------------------------------------------------------------- function makeOutput(model: { api: string; provider: string; id: string }) { return { role: "assistant" as const, content: [] as any[], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop" as string, timestamp: Date.now(), }; } function makeErrorStream(model: { api: string; provider: string; id: string }, message: string) { const stream = createAssistantMessageEventStream(); const output = makeOutput(model); output.stopReason = "error"; output.errorMessage = message; stream.push({ type: "error", reason: "error", error: output }); try { stream.end(); } catch { /* already ended */ } return stream; } // --------------------------------------------------------------------------- // Message helpers // --------------------------------------------------------------------------- function latestTextPrompt(context: any): string { const messages = Array.isArray(context?.messages) ? context.messages : []; const user = [...messages].reverse().find((m: any) => m?.role === "user"); if (!user) return ""; if (typeof user.content === "string") return user.content; return (user.content ?? []) .filter((part: any) => part?.type === "text") .map((part: any) => part.text ?? "") .join("\n"); } function latestAudioRequest(context: any) { const messages = Array.isArray(context?.messages) ? context.messages : []; const user = [...messages].reverse().find((m: any) => m?.role === "user"); if (!user || typeof user.content === "string") return { audios: [] as any[] }; const parts = Array.isArray(user.content) ? user.content : []; const audios = parts .filter((part: any) => part?.type === "audio" || (part?.mimeType ?? "").startsWith("audio/")) .map((part: any) => ({ data: part?.data ?? part?.audio_url?.url ?? part?.audioUrl ?? "", mimeType: part?.mimeType ?? "audio/wav", })) .filter((audio: any) => audio.data); return { audios }; } // --------------------------------------------------------------------------- // Disk helpers for generated media // --------------------------------------------------------------------------- async function saveGeneratedImage(image: { b64_json?: string; url?: string }, modelId: string) { const directory = join(process.cwd(), ".pi", "generated-images"); if (!existsSync(directory)) mkdirSync(directory, { recursive: true }); const mimeType = image?.mime_type ?? image?.mimeType ?? "image/png"; const extension = mimeType.includes("jpeg") ? "jpg" : mimeType.includes("webp") ? "webp" : "png"; const safe = String(modelId).replace(/[\/.]/g, "-"); const filePath = join(directory, `cloudflare-${safe}-${Date.now()}.${extension}`); if (image?.b64_json) { writeFileSync(filePath, Buffer.from(image.b64_json, "base64")); } else if (image?.url) { const response = await fetch(image.url); if (!response.ok) throw new Error(`Unable to download generated image: HTTP ${response.status}`); writeFileSync(filePath, Buffer.from(await response.arrayBuffer())); } else { throw new Error("Image API returned neither url nor b64_json"); } return { filePath, mimeType }; } function saveGeneratedAudio(bytes: ArrayBuffer, modelId: string, extension = "mp3") { const directory = join(process.cwd(), ".pi", "generated-audio"); if (!existsSync(directory)) mkdirSync(directory, { recursive: true }); const safe = String(modelId).replace(/[\/.]/g, "-"); const filePath = join(directory, `cloudflare-${safe}-${Date.now()}.${extension}`); writeFileSync(filePath, Buffer.from(bytes)); return filePath; } function saveTranscript(text: string, modelId: string) { const directory = join(process.cwd(), ".pi", "generated-transcripts"); if (!existsSync(directory)) mkdirSync(directory, { recursive: true }); const safe = String(modelId).replace(/[\/.]/g, "-"); const filePath = join(directory, `cloudflare-${safe}-${Date.now()}.txt`); writeFileSync(filePath, text, "utf-8"); return filePath; } function runUrl(modelId: string): string { const accountId = process.env[ACCOUNT_ID_ENV]; if (!accountId) throw new Error(`${ACCOUNT_ID_ENV} env var is not set; cannot build Workers AI endpoint URL`); return `${RUN_HOST}/${accountId}/ai/run/${modelId}`; } function authHeaders(extra?: Record) { const apiKey = process.env[API_KEY_ENV]; if (!apiKey) throw new Error(`${API_KEY_ENV} env var is required for this model`); return { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...extra }; } // --------------------------------------------------------------------------- // Image generation — POST /ai/run/@cf/black-forest-labs/flux-1-schnell // Body: { prompt }. Response: { result: { image: "" } }. // --------------------------------------------------------------------------- function streamImageGeneration(model: any, context: any, options: any) { const stream = createAssistantMessageEventStream(); const output = makeOutput(model); (async () => { try { stream.push({ type: "start", partial: output }); const prompt = latestTextPrompt(context); if (!prompt) throw new Error("Image generation requires a text prompt"); const response = await fetch(runUrl(model.id), { method: "POST", headers: authHeaders(), body: JSON.stringify({ prompt }), signal: options?.signal, }); const payload = await response.json(); if (!response.ok) throw new Error(payload?.errors?.[0]?.message ?? `Cloudflare image API HTTP ${response.status}`); const saved = await saveGeneratedImage({ b64_json: payload?.result?.image }, model.id); appendCloudflareImage?.({ path: saved.filePath, mimeType: saved.mimeType }); const text = `Generated image saved to: ${fileLink(saved.filePath)}`; output.content.push({ type: "text", text }); stream.push({ type: "text_start", contentIndex: 0, partial: output }); stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output }); stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output }); output.stopReason = "stop"; stream.push({ type: "done", reason: "stop", message: output }); stream.end(); } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : String(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })(); return stream; } // --------------------------------------------------------------------------- // Text-to-speech — POST /ai/run/@cf/deepgram/aura-2-* // Body: { text }. Response body is the raw audio bytes (saved as mp3). // --------------------------------------------------------------------------- function streamTextToSpeech(model: any, context: any, options: any) { const stream = createAssistantMessageEventStream(); const output = makeOutput(model); (async () => { try { stream.push({ type: "start", partial: output }); const prompt = latestTextPrompt(context); if (!prompt) throw new Error("TTS requires text input"); const response = await fetch(runUrl(model.id), { method: "POST", headers: authHeaders(), body: JSON.stringify({ text: prompt }), signal: options?.signal, }); if (!response.ok) throw new Error(`Cloudflare TTS HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`); const path = saveGeneratedAudio(await response.arrayBuffer(), model.id, "mp3"); const text = `Generated audio saved to: ${fileLink(path)}`; output.content.push({ type: "text", text }); stream.push({ type: "text_start", contentIndex: 0, partial: output }); stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output }); stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output }); output.stopReason = "stop"; stream.push({ type: "done", reason: "stop", message: output }); stream.end(); } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : String(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })(); return stream; } // --------------------------------------------------------------------------- // Speech-to-text — two verified transports: // • whisper (@cf/openai/whisper): POST JSON { audio: <0–255 byte array> }; // a base64 string / object is rejected with HTTP 400. Response { result: { text } }. // • Deepgram nova (@cf/deepgram/nova-3, flag cloudflareAsrRawBody): POST the raw // audio bytes as the body with Content-Type: audio/*. Response: // { result: { results: { channels: [{ alternatives: [{ transcript }] }] } } }. // --------------------------------------------------------------------------- function streamTranscription(model: any, context: any, options: any) { const stream = createAssistantMessageEventStream(); const output = makeOutput(model); (async () => { try { stream.push({ type: "start", partial: output }); const { audios } = latestAudioRequest(context); if (audios.length === 0) throw new Error("Transcription requires an audio input (attach an audio file)"); const audio = audios[0]; const url = runUrl(model.id); let response; if (model.cloudflareAsrRawBody) { // Deepgram nova: raw audio bytes as the body, with an audio/* Content-Type. response = await fetch(url, { method: "POST", headers: authHeaders({ "Content-Type": audio.mimeType || "audio/wav" }), body: Buffer.from(audio.data, "base64"), signal: options?.signal, }); } else { // whisper: { audio: <0–255 byte array> } as JSON. let audioBytes: number[]; try { audioBytes = Array.from(Buffer.from(audio.data, "base64")); } catch { throw new Error("Failed to decode attached audio (expected base64)"); } response = await fetch(url, { method: "POST", headers: authHeaders(), body: JSON.stringify({ audio: audioBytes }), signal: options?.signal, }); } if (!response.ok) throw new Error(`Cloudflare transcription HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`); const payload = await response.json(); const result = payload?.result ?? payload; const transcript = result?.text ?? result?.transcript ?? result?.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? payload?.text ?? ""; if (!transcript) throw new Error("Transcription returned no text"); const path = saveTranscript(transcript, model.id); const text = `Transcription (${model.id}):\n\n${transcript}\n\nSaved transcript to: ${fileLink(path)}`; output.content.push({ type: "text", text }); stream.push({ type: "text_start", contentIndex: 0, partial: output }); stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output }); stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output }); output.stopReason = "stop"; stream.push({ type: "done", reason: "stop", message: output }); stream.end(); } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : String(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })(); return stream; } // --------------------------------------------------------------------------- // Chat — delegated to pi's openai-completions core. The account id is injected // into the base URL at request time (it cannot live in the static baseUrl). // --------------------------------------------------------------------------- function streamCloudflareChat(model: any, context: any, options: any) { const accountId = process.env[ACCOUNT_ID_ENV]; if (!accountId) { return makeErrorStream(model, `${ACCOUNT_ID_ENV} env var is not set; cannot build Workers AI endpoint URL`); } const apiKey = process.env[API_KEY_ENV]; if (!apiKey) { return makeErrorStream(model, `${API_KEY_ENV} env var is not set; required for Cloudflare Workers AI`); } const baseUrl = `${RUN_HOST}/${accountId}/ai/v1`; // Clone with the real base URL; the openai-completions core reads model.baseUrl. const resolved = { ...model, baseUrl }; return openAICompletionsApi().streamSimple(resolved, context, { ...options, apiKey, }); } function streamCloudflare(model: any, context: any, options: any) { if (model.cloudflareImageModel) return streamImageGeneration(model, context, options); if (model.cloudflareAudioModel) return streamTextToSpeech(model, context, options); if (model.cloudflareTranscriptionModel) return streamTranscription(model, context, options); return streamCloudflareChat(model, context, options); } // --------------------------------------------------------------------------- // Curated free-tier model list // --------------------------------------------------------------------------- // Verified free models (2026-08). Context windows / max output come from // Cloudflare's model docs. The 5 paid frontier models are intentionally absent. const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; function chatModel(id: string, name: string, contextWindow: number, maxTokens: number, reasoning = true) { return { id, name, api: "openai-completions", reasoning, input: ["text"], cost: { ...ZERO_COST }, contextWindow, maxTokens, }; } const CLOUDFLARE_MODELS = [ // ── Chat / reasoning (OpenAI-compatible) ── chatModel("@cf/openai/gpt-oss-120b", "GPT-OSS 120B (Cloudflare)", 131072, 65536), chatModel("@cf/openai/gpt-oss-20b", "GPT-OSS 20B (Cloudflare)", 131072, 65536), chatModel("@cf/meta/llama-3.3-70b-instruct-fp8-fast", "Llama 3.3 70B (Cloudflare)", 24000, 8192), chatModel("@cf/qwen/qwen3-30b-a3b-fp8", "Qwen3 30B A3B (Cloudflare)", 32768, 8192), chatModel("@cf/qwen/qwen2.5-coder-32b-instruct", "Qwen2.5 Coder 32B (Cloudflare)", 32768, 8192), chatModel("@cf/google/gemma-4-26b-a4b-it", "Gemma 4 26B (Cloudflare)", 131072, 65536), chatModel("@cf/zai-org/glm-4.7-flash", "GLM-4.7-Flash (Cloudflare)", 131072, 65536), chatModel("@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", "DeepSeek R1 Distill Qwen 32B (Cloudflare)", 80000, 65536), // ── Image generation (native /ai/run) ── { id: "@cf/black-forest-labs/flux-1-schnell", name: "FLUX.1 Schnell (Cloudflare image)", api: "openai-completions", input: ["text"], cost: { ...ZERO_COST }, contextWindow: 131072, maxTokens: 4096, cloudflareImageModel: true, }, // ── Text-to-speech (native /ai/run) ── { id: "@cf/deepgram/aura-2-en", name: "Deepgram Aura 2 English (Cloudflare TTS)", api: "openai-completions", input: ["text"], cost: { ...ZERO_COST }, contextWindow: 131072, maxTokens: 4096, cloudflareAudioModel: true, }, { id: "@cf/deepgram/aura-2-es", name: "Deepgram Aura 2 Spanish (Cloudflare TTS)", api: "openai-completions", input: ["text"], cost: { ...ZERO_COST }, contextWindow: 131072, maxTokens: 4096, cloudflareAudioModel: true, }, // ── Speech-to-text (native /ai/run) ── { id: "@cf/openai/whisper", name: "OpenAI Whisper (Cloudflare ASR)", api: "openai-completions", input: ["audio"], cost: { ...ZERO_COST }, contextWindow: 131072, maxTokens: 4096, cloudflareTranscriptionModel: true, }, { id: "@cf/deepgram/nova-3", name: "Deepgram Nova-3 (Cloudflare ASR)", api: "openai-completions", input: ["audio"], cost: { ...ZERO_COST }, contextWindow: 131072, maxTokens: 4096, cloudflareTranscriptionModel: true, cloudflareAsrRawBody: true, }, ]; // --------------------------------------------------------------------------- // Commands: /cloudflare-models, /cloudflare-usage // --------------------------------------------------------------------------- const DASHBOARD_URL = "https://dash.cloudflare.com/"; function modelType(m: any): "image" | "tts" | "asr" | "chat" { if (m.cloudflareImageModel) return "image"; if (m.cloudflareAudioModel) return "tts"; if (m.cloudflareTranscriptionModel) return "asr"; return "chat"; } function fmtSize(n: number): string { if (!n) return "—"; if (n >= 1_048_576) return `${Math.round(n / 1_048_576)}M`; if (n >= 1024) return `${Math.round(n / 1024)}K`; return String(n); } const TYPE_LABEL: Record = { chat: "chat", image: "image", tts: "tts", asr: "asr" }; function buildModelsMarkdown(models: any[], filter?: string): string { const rows = models .map((m) => ({ id: m.id, type: modelType(m), reasoning: !!m.reasoning, ctx: m.contextWindow, max: m.maxTokens })) .filter((r) => !filter || r.type === filter) .sort((a, b) => TYPE_LABEL[a.type].localeCompare(TYPE_LABEL[b.type]) || a.id.localeCompare(b.id)); const head = [`# Cloudflare Workers AI models${filter ? ` (filter: ${filter})` : ""}`, "", `_${rows.length} model(s) — free tier: 10,000 neurons/day_`]; if (rows.length === 0) return [...head, "", "_No Cloudflare models match that filter._"].join("\n"); const tick = (v: boolean) => (v ? "✓" : "—"); return [ ...head, "", "| Model | Type | Reasoning | Context | Max Out |", "|---|---|:---:|:---:|---:|", ...rows.map( (r) => `| \`${r.id}\` | ${r.type} | ${tick(r.reasoning)} | ${fmtSize(r.ctx)} | ${fmtSize(r.max)} |` ), "", "_Context windows / max output are from Cloudflare's model docs. The 5 paid frontier models (deepseek-v4-flash/pro, glm-5.2, kimi-k2.6/k2.7-code) are not registered._", ].join("\n"); } function cloudflareModels(ctx: any) { return ctx.modelRegistry.getAvailable().filter((m: any) => m.provider === "cloudflare-workers-ai"); } function notifyOrPrint(ctx: any, message: string, level: "info" | "warning" = "warning") { if (ctx.hasUI) ctx.ui.notify(message, level); else console.log(message); } function openBrowser(url: string) { const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; try { execFile(cmd, args, () => {}); } catch { /* best-effort */ } } function registerCloudflareCommands(pi: any) { const registerMarkdownCommand = ( name: string, description: string, getArgumentCompletions: (prefix: string) => { value: string; label: string }[] | null, build: (args: string, models: any[]) => string, ) => { pi.registerCommand(name, { description, getArgumentCompletions, handler: async (args: string, ctx: any) => { const models = cloudflareModels(ctx); if (models.length === 0) { notifyOrPrint(ctx, "No Cloudflare Workers AI models available — set CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID and restart pi."); return; } const markdown = build(args, models); if (ctx.mode === "tui") pi.appendEntry(name, { markdown }); else if (ctx.hasUI) ctx.ui.notify(markdown, "info"); else console.log(markdown); }, }); pi.registerEntryRenderer?.(name, (entry: any) => new Markdown(entry.data?.markdown ?? "", 1, 0, getMarkdownTheme())); }; const tokensOf = (args: string) => (args || "").trim().split(/\s+/).filter(Boolean); const completionsFor = (values: string[]) => (prefix: string) => { const items = values.map((value) => ({ value, label: value })); const filtered = items.filter((i) => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; }; registerMarkdownCommand( "cloudflare-models", "List Cloudflare Workers AI models (chat/image/tts/asr); e.g. /cloudflare-models image", completionsFor(Object.keys(TYPE_LABEL)), (args, models) => buildModelsMarkdown(models, tokensOf(args).find((t) => t in TYPE_LABEL)), ); pi.registerCommand("cloudflare-usage", { description: "Open the Cloudflare Workers AI usage page in the dashboard.", handler: async (_args: string, ctx: any) => { const accountId = process.env[ACCOUNT_ID_ENV]; if (!accountId) { notifyOrPrint(ctx, "CLOUDFLARE_ACCOUNT_ID env var is not set; cannot open Workers AI usage page.", "warning"); return; } const usageUrl = `https://dash.cloudflare.com/${accountId}/ai/workers-ai/usage`; openBrowser(usageUrl); const note = `Opened Workers AI usage page: ${usageUrl}`; if (ctx.mode === "tui") pi.appendEntry("cloudflare-usage", { markdown: `# Cloudflare Workers AI usage\n\n${note}` }); else notifyOrPrint(ctx, note, "info"); }, }); pi.registerEntryRenderer?.("cloudflare-usage", (entry: any) => new Markdown(entry.data?.markdown ?? "", 1, 0, getMarkdownTheme())); } // --------------------------------------------------------------------------- // Extension entry point (synchronous — no network on startup) // --------------------------------------------------------------------------- export default function (pi: any) { appendCloudflareImage = (image) => pi.appendEntry("cloudflare-generated-image", image); pi.registerEntryRenderer?.("cloudflare-generated-image", (entry: any, _options: any, theme: any) => { const image = entry.data ?? {}; // pi passes an entry-renderer theme that lacks fallbackColor(), which // Image.render calls. Wrap it so inline previews render and never throw. const imageTheme = theme && typeof theme.fallbackColor === "function" ? theme : { fallbackColor: (s: string) => (theme && theme.fg ? theme.fg("toolOutput", s) : s) }; try { const data = readFileSync(image.path).toString("base64"); return new Image(data, image.mimeType || "image/png", imageTheme, { maxWidthCells: 80, maxHeightCells: 30 }); } catch { return new Markdown(`Generated image unavailable: ${fileLink(image.path ?? "unknown path")}`, 1, 0, theme); } }); registerCloudflareCommands(pi); pi.registerProvider("cloudflare-workers-ai", { name: "Cloudflare Workers AI", // Placeholder account id kept out of the static baseUrl; streamCloudflare // injects CLOUDFLARE_ACCOUNT_ID into the path at request time. baseUrl: `${RUN_HOST}/{account_id}/ai/v1`, // Keep this as an env reference even when the variable is absent, so pi can // mark the provider as unconfigured instead of using a placeholder key. apiKey: `$${API_KEY_ENV}`, api: "openai-completions", streamSimple: streamCloudflare, models: CLOUDFLARE_MODELS, }); }