feat(ai): add Cloudflare Clef classifiers to Workers AI (#10316)

builds on https://github.com/earendil-works/pi/pull/10322 by @RealAlexandreAI
This commit is contained in:
Nathan Disidore 2026-10-02 09:52:58 -05:00 • committed by GitHub
parent c10bfb0d79
commit 4812cb268e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 83 additions and 9 deletions

View file

@ -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` |

View file

@ -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",

View file

@ -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;
},
};

View file

@ -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<string, { type: string }> };
};
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, {