Skip to content

Commit cda1fd1

Browse files
bchapuisclaude
andcommitted
Add generic Cloudflare Workers AI model node
Lets users run any model from the Cloudflare catalog via a searchable dialog; the node rebuilds its inputs, outputs, and docs from the model's published schema. Proxy routes cache the catalog and per-model schemas via the Workers Cache API, and a per-model pricing catalog maps real CF costs to Dafthunk usage credits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f49c7b commit cda1fd1

15 files changed

Lines changed: 2370 additions & 7 deletions

File tree

apps/api/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import adminRoutes from "./routes/admin";
1111
import apiKeyRoutes from "./routes/api-keys";
1212
import billingRoutes from "./routes/billing";
1313
import botRoutes from "./routes/bots";
14+
import cloudflareAiRoutes from "./routes/cloudflare-ai";
1415
import dashboardRoutes from "./routes/dashboard";
1516
import databaseRoutes from "./routes/databases";
1617
import datasetRoutes from "./routes/datasets";
@@ -107,6 +108,9 @@ app.route("/queues", queuePublishRoutes);
107108
// Replicate schema proxy (JWT-authenticated, not org-scoped)
108109
app.route("/replicate", replicateRoutes);
109110

111+
// Cloudflare Workers AI schema proxy (JWT-authenticated, not org-scoped)
112+
app.route("/cloudflare-ai", cloudflareAiRoutes);
113+
110114
// Public routes
111115
app.route("/forms", formRoutes);
112116
app.route("/feedback-forms", feedbackFormRoutes);
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import {
2+
type CloudflareModelOpenApiSchema,
3+
mapCloudflareSchema,
4+
} from "@dafthunk/runtime/utils/cloudflare-schema";
5+
import type {
6+
CloudflareModelInfo,
7+
CloudflareModelSchema,
8+
} from "@dafthunk/types";
9+
import { Hono } from "hono";
10+
11+
import { jwtMiddleware } from "../auth";
12+
import type { ApiContext } from "../context";
13+
import { cachedJson } from "../utils/edge-cache";
14+
15+
const cloudflareAiRoutes = new Hono<ApiContext>();
16+
17+
cloudflareAiRoutes.use("*", jwtMiddleware);
18+
19+
/** Cache TTLs (seconds). Upper bounds — the Workers cache may evict earlier. */
20+
const MODELS_LIST_TTL = 60 * 60; // 1h — catalog additions are rare
21+
const MODEL_SCHEMA_TTL = 24 * 60 * 60; // 24h — model schemas almost never change
22+
23+
/**
24+
* Per-page size requested from the Cloudflare models/search endpoint. If the
25+
* response hits this count, the catalog may be larger than we fetched — we
26+
* log a warning so operators know to either raise this value or paginate.
27+
*/
28+
const MODELS_LIST_PAGE_SIZE = 200;
29+
30+
/** Synthetic host used as the Cache API key namespace for this route. */
31+
const CACHE_HOST = "https://cache.dafthunk.internal";
32+
33+
/**
34+
* Error thrown when the upstream Cloudflare API returns a non-success status.
35+
* Kept as a typed error so we don't cache failed responses and can map back
36+
* to the right HTTP status.
37+
*/
38+
class CloudflareApiError extends Error {
39+
constructor(
40+
message: string,
41+
public readonly status: number
42+
) {
43+
super(message);
44+
}
45+
}
46+
47+
/**
48+
* Normalise a Cloudflare model identifier. Accepts the canonical form
49+
* `@cf/provider/name` as well as values with the leading `@` url-encoded
50+
* (e.g. `%40cf/provider/name`).
51+
*/
52+
function normaliseModelId(raw: string): string {
53+
const decoded = decodeURIComponent(raw).trim();
54+
if (!decoded) return "";
55+
return decoded.startsWith("@") ? decoded : `@${decoded}`;
56+
}
57+
58+
/**
59+
* Fetch a Cloudflare API endpoint and throw on failure. Throwing is important
60+
* so `cachedJson` doesn't persist error responses.
61+
*/
62+
async function callCloudflare<T>(url: string, apiToken: string): Promise<T> {
63+
const response = await fetch(url, {
64+
headers: {
65+
Authorization: `Bearer ${apiToken}`,
66+
"Content-Type": "application/json",
67+
},
68+
});
69+
70+
if (!response.ok) {
71+
throw new CloudflareApiError(
72+
`Cloudflare API error: ${response.status} ${response.statusText}`,
73+
response.status
74+
);
75+
}
76+
77+
const json = (await response.json()) as {
78+
success: boolean;
79+
errors?: Array<{ message?: string }>;
80+
result?: T;
81+
};
82+
83+
if (!json.success || !json.result) {
84+
const message = json.errors?.[0]?.message ?? "Unknown error";
85+
throw new CloudflareApiError(`Cloudflare API error: ${message}`, 502);
86+
}
87+
88+
return json.result;
89+
}
90+
91+
/**
92+
* Keep only the fields the UI actually consumes. Dropping CF's `properties`,
93+
* `tags`, `source`, and other passthrough fields shrinks the payload and
94+
* avoids leaking anything we don't deliberately expose.
95+
*/
96+
function trimModelInfo(raw: unknown): CloudflareModelInfo | null {
97+
if (!raw || typeof raw !== "object") return null;
98+
const r = raw as Record<string, unknown>;
99+
if (typeof r.id !== "string" || typeof r.name !== "string") return null;
100+
101+
const task =
102+
r.task && typeof r.task === "object"
103+
? (r.task as Record<string, unknown>)
104+
: null;
105+
106+
return {
107+
id: r.id,
108+
name: r.name,
109+
...(typeof r.description === "string"
110+
? { description: r.description }
111+
: {}),
112+
...(task && typeof task.id === "string" && typeof task.name === "string"
113+
? { task: { id: task.id, name: task.name } }
114+
: {}),
115+
};
116+
}
117+
118+
/**
119+
* Consistent error response mapping Cloudflare failures to HTTP statuses.
120+
*/
121+
function handleCloudflareError(
122+
c: {
123+
json: (body: object, status: 400 | 404 | 500 | 502) => Response;
124+
},
125+
err: unknown
126+
) {
127+
if (err instanceof CloudflareApiError) {
128+
const status: 404 | 502 = err.status === 404 ? 404 : 502;
129+
return c.json({ error: err.message }, status);
130+
}
131+
return c.json(
132+
{ error: err instanceof Error ? err.message : "Unknown error" },
133+
502
134+
);
135+
}
136+
137+
/**
138+
* GET /cloudflare-ai/models/schema?model=@cf/...
139+
*
140+
* Fetches a Cloudflare Workers AI model's JSON schema, maps it to Dafthunk
141+
* Parameters, and caches the mapped result in the Workers runtime cache.
142+
*/
143+
cloudflareAiRoutes.get("/models/schema", async (c) => {
144+
const accountId = c.env.CLOUDFLARE_ACCOUNT_ID;
145+
const apiToken = c.env.CLOUDFLARE_API_TOKEN;
146+
147+
if (!accountId || !apiToken) {
148+
return c.json(
149+
{
150+
error:
151+
"CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN must be configured",
152+
},
153+
500
154+
);
155+
}
156+
157+
const modelParam = c.req.query("model");
158+
if (!modelParam) {
159+
return c.json({ error: "model query parameter is required" }, 400);
160+
}
161+
162+
const model = normaliseModelId(modelParam);
163+
const cacheKey = `${CACHE_HOST}/cf-ai/models/schema/${encodeURIComponent(model)}`;
164+
165+
try {
166+
const payload = await cachedJson<CloudflareModelSchema>(
167+
cacheKey,
168+
MODEL_SCHEMA_TTL,
169+
c.executionCtx,
170+
async () => {
171+
const url = new URL(
172+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/models/schema`
173+
);
174+
url.searchParams.set("model", model);
175+
const raw = await callCloudflare<CloudflareModelOpenApiSchema>(
176+
url.toString(),
177+
apiToken
178+
);
179+
const { inputs, outputs } = mapCloudflareSchema(raw);
180+
return { model, inputs, outputs };
181+
}
182+
);
183+
return c.json(payload);
184+
} catch (err) {
185+
return handleCloudflareError(c, err);
186+
}
187+
});
188+
189+
/**
190+
* GET /cloudflare-ai/models
191+
*
192+
* Returns the trimmed Cloudflare Workers AI catalog. The full catalog is
193+
* fetched once per TTL window and filtered client-side by the widget; the
194+
* response carries only the fields the frontend actually renders.
195+
*/
196+
cloudflareAiRoutes.get("/models", async (c) => {
197+
const accountId = c.env.CLOUDFLARE_ACCOUNT_ID;
198+
const apiToken = c.env.CLOUDFLARE_API_TOKEN;
199+
200+
if (!accountId || !apiToken) {
201+
return c.json(
202+
{
203+
error:
204+
"CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN must be configured",
205+
},
206+
500
207+
);
208+
}
209+
210+
const cacheKey = `${CACHE_HOST}/cf-ai/models/list`;
211+
212+
try {
213+
const payload = await cachedJson<{ models: CloudflareModelInfo[] }>(
214+
cacheKey,
215+
MODELS_LIST_TTL,
216+
c.executionCtx,
217+
async () => {
218+
const url = new URL(
219+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/models/search`
220+
);
221+
url.searchParams.set("per_page", String(MODELS_LIST_PAGE_SIZE));
222+
url.searchParams.set("hide_experimental", "true");
223+
const raw = await callCloudflare<unknown[]>(url.toString(), apiToken);
224+
if (raw.length >= MODELS_LIST_PAGE_SIZE) {
225+
console.warn(
226+
`[cloudflare-ai] Catalog returned ${raw.length} entries (page size ${MODELS_LIST_PAGE_SIZE}); the list may be truncated — consider adding pagination.`
227+
);
228+
}
229+
const models = raw
230+
.map(trimModelInfo)
231+
.filter((m): m is CloudflareModelInfo => m !== null);
232+
return { models };
233+
}
234+
);
235+
return c.json(payload);
236+
} catch (err) {
237+
return handleCloudflareError(c, err);
238+
}
239+
});
240+
241+
export default cloudflareAiRoutes;

apps/api/src/runtime/cloudflare-node-registry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { CloudflareBrowserPdfNode } from "@dafthunk/runtime/nodes/browser/cloudf
6060
import { CloudflareBrowserScrapeNode } from "@dafthunk/runtime/nodes/browser/cloudflare-browser-scrape-node";
6161
import { CloudflareBrowserScreenshotNode } from "@dafthunk/runtime/nodes/browser/cloudflare-browser-screenshot-node";
6262
import { CloudflareBrowserSnapshotNode } from "@dafthunk/runtime/nodes/browser/cloudflare-browser-snapshot-node";
63+
import { CloudflareModelNode } from "@dafthunk/runtime/nodes/cloudflare/cloudflare-model-node";
6364
import { CsvExtractColumnNode } from "@dafthunk/runtime/nodes/csv/csv-extract-column-node";
6465
import { CsvFilterRowsNode } from "@dafthunk/runtime/nodes/csv/csv-filter-rows-node";
6566
import { CsvParseNode } from "@dafthunk/runtime/nodes/csv/csv-parse-node";
@@ -706,6 +707,9 @@ export class CloudflareNodeRegistry extends BaseNodeRegistry<Bindings> {
706707
// Generic Replicate model node
707708
this.registerImplementation(ReplicateModelNode);
708709

710+
// Generic Cloudflare Workers AI model node
711+
this.registerImplementation(CloudflareModelNode);
712+
709713
// Video processing nodes (Cloudflare Containers)
710714
if (this.env.FFMPEG_CONTAINER) {
711715
this.registerImplementation(AppendVideosNode);

apps/api/src/utils/edge-cache.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Thin wrapper around the Workers Cache API for proxying upstream read-only
3+
* JSON endpoints. On cache miss the fetcher runs, its result is serialised
4+
* and stored with an explicit `Cache-Control: max-age=…`, and returned to
5+
* the caller. Cache writes happen via `ctx.waitUntil` so responses never
6+
* block on cache population.
7+
*
8+
* Cache keys should be stable synthetic URLs that don't collide with real
9+
* routes — conventionally `https://cache.dafthunk.internal/<feature>/<key>`.
10+
*
11+
* Notes:
12+
* - Workers Cache API is per-POP and best-effort; TTLs are upper bounds.
13+
* Acceptable for static catalogs we're willing to serve slightly stale.
14+
* - Only JSON-serialisable values are supported. Non-JSON responses belong
15+
* in the raw Response-based Cache API idiom.
16+
*/
17+
export async function cachedJson<T>(
18+
cacheKey: string,
19+
ttlSeconds: number,
20+
ctx: ExecutionContext,
21+
fetcher: () => Promise<T>
22+
): Promise<T> {
23+
const request = new Request(cacheKey);
24+
const cache = caches.default;
25+
26+
const hit = await cache.match(request);
27+
if (hit) {
28+
return (await hit.json()) as T;
29+
}
30+
31+
const fresh = await fetcher();
32+
const response = new Response(JSON.stringify(fresh), {
33+
headers: {
34+
"Content-Type": "application/json",
35+
"Cache-Control": `public, max-age=${ttlSeconds}`,
36+
},
37+
});
38+
ctx.waitUntil(cache.put(request, response.clone()));
39+
return fresh;
40+
}

apps/app/src/components/workflow/widgets/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { audioRecorderInputWidget } from "./input/audio-recorder-input";
1111
import { blobInputWidget } from "./input/blob-input";
1212
import { booleanInputWidget } from "./input/boolean-input";
1313
import { canvasInputWidget } from "./input/canvas-input";
14+
import { cloudflareModelInputWidget } from "./input/cloudflare-model-input";
1415
import { cronInputWidget } from "./input/cron-input";
1516
import { dateInputWidget } from "./input/date-input";
1617
import { discordTriggerInputWidget } from "./input/discord-trigger-input";
@@ -87,6 +88,7 @@ const widgets = [
8788
audioRecorderInputWidget,
8889
canvasInputWidget,
8990
replicateModelInputWidget,
91+
cloudflareModelInputWidget,
9092
schemaComposeInputWidget,
9193
schemaExtractInputWidget,
9294
createDynamicInputsWidget("string-concat", {

0 commit comments

Comments
 (0)