From 4812cb268efc3f0846337cdfb8f00ce51b7b8d5a Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 2 Oct 2026 09:52:58 -0500 Subject: [PATCH] feat(ai): add Cloudflare Clef classifiers to Workers AI (#10316) builds on https://github.com/earendil-works/pi/pull/10322 by @RealAlexandreAI --- packages/ai/README.md | 2 +- packages/ai/scripts/generate-models.ts | 26 +++++++++++ .../api/cloudflare-workers-ai-system-one.ts | 20 +++++---- .../cloudflare-workers-ai-system-one.test.ts | 44 +++++++++++++++++++ 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index 9b4e0d955..e80bee764 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -896,7 +896,7 @@ Classifier models consume structured JSON state and answer one or more typed que | --- | --- | --- | | `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` | | `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or OpenRouter OAuth | -| `cloudflare-workers-ai` | `typesafe/jev` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | +| `cloudflare-workers-ai` | `typesafe/jev`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` | | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index c1730928b..bafbf93d9 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2738,6 +2738,32 @@ const OPENCODE_CLASSIFIER_MODELS: ClassifierModel<"typesafe-system-one">[] = [ ]; const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-workers-ai-system-one">[] = [ + // Cloudflare-hosted Clef decision models. They accept images, but classifier + // contexts carry text or JSON state only, so the catalog advertises text. + // Pricing: https://developers.cloudflare.com/workers-ai/models/clef/ + // and https://developers.cloudflare.com/workers-ai/models/clef-flash/ + { + type: "classifier", + id: "@cf/cloudflare/clef", + name: "Clef", + api: "cloudflare-workers-ai-system-one", + provider: "cloudflare-workers-ai", + baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, + input: ["text"], + cost: { input: 0.24, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 65536, + }, + { + type: "classifier", + id: "@cf/cloudflare/clef-flash", + name: "Clef Flash", + api: "cloudflare-workers-ai-system-one", + provider: "cloudflare-workers-ai", + baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, + input: ["text"], + cost: { input: 0.09, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 65536, + }, { type: "classifier", id: "typesafe/jev", diff --git a/packages/ai/src/api/cloudflare-workers-ai-system-one.ts b/packages/ai/src/api/cloudflare-workers-ai-system-one.ts index fbe11ded9..2aaae8c18 100644 --- a/packages/ai/src/api/cloudflare-workers-ai-system-one.ts +++ b/packages/ai/src/api/cloudflare-workers-ai-system-one.ts @@ -15,10 +15,13 @@ function cloudflareErrorMessage(errors: unknown): string { /** * System One models on the Workers AI REST endpoint: - * `POST /accounts/{account}/ai/run` with `{ model, input }`. The REST API - * wraps the model output in Cloudflare's API envelope and a run record: + * `POST /accounts/{account}/ai/run` with `{ model, input }`. The REST API wraps the model + * output in Cloudflare's API envelope. Third-party models such as `typesafe/jev` add a run record: * `{ success, result: { state: "Completed", result: { answers, usage } } }`. * https://developers.cloudflare.com/ai/models/typesafe/jev/ + * Cloudflare-hosted models such as `@cf/cloudflare/clef` return the output directly: + * `{ success, result: { model, answers, usage } }`. + * https://developers.cloudflare.com/workers-ai/models/clef/ */ const transport: SystemOneTransport = { api: "cloudflare-workers-ai-system-one", @@ -28,13 +31,14 @@ const transport: SystemOneTransport = { output: (body) => { if (!isRecord(body)) throw new Error(`${LABEL} returned an unexpected response`); if (body.success === false) throw new Error(cloudflareErrorMessage(body.errors)); - const run = body.result; - if (!isRecord(run)) throw new Error(`${LABEL} returned an unexpected response`); - if (run.state !== "Completed") { - throw new Error(`${LABEL} run did not complete (state: ${String(run.state)})`); + const result = body.result; + if (!isRecord(result)) throw new Error(`${LABEL} returned an unexpected response`); + if ("answers" in result) return result; + if (result.state !== "Completed") { + throw new Error(`${LABEL} run did not complete (state: ${String(result.state)})`); } - if (!isRecord(run.result)) throw new Error(`${LABEL} returned an unexpected response`); - return run.result; + if (!isRecord(result.result)) throw new Error(`${LABEL} returned an unexpected response`); + return result.result; }, }; diff --git a/packages/ai/test/cloudflare-workers-ai-system-one.test.ts b/packages/ai/test/cloudflare-workers-ai-system-one.test.ts index cd79ec90a..d7ec82e49 100644 --- a/packages/ai/test/cloudflare-workers-ai-system-one.test.ts +++ b/packages/ai/test/cloudflare-workers-ai-system-one.test.ts @@ -45,6 +45,22 @@ function restResponse(state: string, result: unknown = jevOutput) { }; } +// Cloudflare-hosted output observed from a live /ai/run call, question ids renamed to match `context`. +// The envelope carries the output directly, without a run record. +const clefOutput = { + model: "clef", + answers: { + is_urgent: { type: "noul", noul: 0.9912 }, + department: { + type: "choice", + choice: "technical", + probabilities: { billing: 0.1632, technical: 0.8368 }, + confidence: 0.4538, + }, + }, + usage: { input_tokens: 222, output_tokens: 0 }, +}; + function setup() { const models = createModels(); models.setProvider(cloudflareWorkersAIProvider()); @@ -87,6 +103,34 @@ describe("Cloudflare Workers AI System One", () => { expect(result.usage).toMatchObject({ input: 426, output: 73, totalTokens: 499 }); }); + it.each([ + ["@cf/cloudflare/clef", 0.24], + ["@cf/cloudflare/clef-flash", 0.09], + ])("runs %s through /ai/run and parses its direct output", async (id, inputPrice) => { + const { models } = setup(); + const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", id); + if (!clef) throw new Error(`missing Cloudflare ${id} model`); + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const payload = JSON.parse(String(init?.body)) as { + model: string; + input: { state: unknown; questions: Record }; + }; + expect(payload.model).toBe(id); + expect(payload.input.state).toEqual(context.state); + expect(payload.input.questions.is_urgent?.type).toBe("noul"); + return Response.json({ result: clefOutput, success: true, errors: [], messages: [] }); + }); + + const result = await models.classify(clef, context, { ...auth, fetch }); + + expect(String(fetch.mock.calls[0]?.[0])).toBe("https://api.cloudflare.com/client/v4/accounts/account-id/ai/run"); + expect(result.stopReason).toBe("stop"); + expect(result.answers.is_urgent).toEqual({ type: "bool", probability: 0.9912 }); + expect(result.answers.department).toMatchObject({ type: "choice", choice: "technical", confidence: 0.4538 }); + expect(result.usage).toMatchObject({ input: 222, output: 0, totalTokens: 222 }); + expect(result.usage?.cost.input).toBeCloseTo((222 * inputPrice) / 1_000_000); + }); + it("reports runs that did not complete", async () => { const { models, jev } = setup(); const result = await models.classify(jev, context, {