Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 223 additions & 73 deletions packages/core/src/sync/providers/cloudflare-workers-ai.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { z } from "zod";
import { readdirSync } from "node:fs";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";

import type { ExistingModel, SyncedModel, SyncProvider } from "../index.js";
import {
buildOpenRouterModel,
OpenRouterModel,
OpenRouterResponse,
} from "./openrouter.js";
import type { ExistingModel, SyncedFullModel, SyncedModel, SyncProvider } from "../index.js";
import { MissingReasoningOptionsError } from "../missing-reasoning-options.js";
import { buildOpenRouterModel, OpenRouterModel } from "./openrouter.js";

const API_BASE = "https://api.cloudflare.com/client/v4/accounts";
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
const TOGGLE_HEADER = "# Toggle: chat_template_kwargs.enable_thinking = true|false\n";
// Models with a verified enable_thinking control. DeepSeek/Kimi use other wire paths;
// their authored comments are preserved below, never inferred from generic schemas.
// These are exact model IDs, not a default for future models or whole publishers.
const ENABLE_THINKING_MODELS = new Set([
"@cf/google/gemma-4-26b-a4b-it",
"@cf/nvidia/nemotron-3-120b-a12b",
"@cf/qwen/qwen3.8-27b",
"@cf/zai-org/glm-4.7-flash",
"@cf/zai-org/glm-5.2",
]);
const ROOT_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..");
const MODELS_DIR = path.join(ROOT_DIR, "models");
const metadataFilesByPublisher = new Map<string, string[]>();
const METADATA_PUBLISHERS: Record<string, string> = {
"deepseek-ai": "deepseek",
Expand All @@ -24,38 +33,59 @@ const METADATA_PUBLISHERS: Record<string, string> = {
"zai-org": "zhipuai",
};

const CloudflareOpenRouterResponse = z.object({
result: z.union([OpenRouterResponse, z.array(OpenRouterModel)]).optional(),
result_info: z.object({
page: z.number().optional(),
total_pages: z.number().optional(),
}).passthrough().optional(),
}).passthrough();
const WorkersAiReasoning = OpenRouterModel.shape.reasoning.unwrap().partial({ mandatory: true });
const WorkersAiModel = OpenRouterModel.extend({ reasoning: WorkersAiReasoning.optional() });
type WorkersAiModel = z.infer<typeof WorkersAiModel>;

const CloudflareModel = z.object({
id: z.string(),
name: z.string(),
created: z.number(),
id: z.string().trim().min(1),
name: z.string().trim().min(1),
created: z.number().int().nonnegative(),
hugging_face_id: z.string().nullable().optional(),
context_length: z.number(),
max_output_length: z.number().nullable().optional(),
input_modalities: z.array(z.string()).optional(),
output_modalities: z.array(z.string()).optional(),
pricing: OpenRouterModel.shape.pricing,
context_length: z.number().int().positive(),
max_output_length: z.number().int().positive().nullable().optional(),
input_modalities: z.array(z.string().min(1)).min(1).optional(),
output_modalities: z.array(z.string().min(1)).min(1).optional(),
pricing: OpenRouterModel.shape.pricing.extend({
prompt: z.string().refine(validPrice),
completion: z.string().refine(validPrice),
}),
supported_features: z.array(z.string()).optional(),
supported_sampling_parameters: z.array(z.string()).optional(),
// Validate reasoning separately so malformed controls do not discard the model.
reasoning: z.unknown().optional(),
}).passthrough();

const CloudflareResponse = z.object({
data: z.array(CloudflareModel),
}).passthrough();
data: z.array(z.unknown()).optional(),
result: z.union([
z.array(z.unknown()),
z.object({ data: z.array(z.unknown()) }),
]).optional(),
success: z.literal(true).optional(),
result_info: z.object({
total_pages: z.number().int().positive().optional(),
}).optional(),
}).refine((response) => response.data !== undefined || response.result !== undefined, {
message: "Cloudflare Workers AI response did not include model data",
});

function modelRows(response: z.infer<typeof CloudflareResponse>) {
return response.data ?? (Array.isArray(response.result) ? response.result : response.result!.data);
}

function validPrice(value: string) {
return value.trim() !== "" && Number.isFinite(Number(value)) && Number(value) >= 0;
}

type CloudflareModel = z.infer<typeof CloudflareModel>;

export const cloudflareWorkersAi = {
id: "cloudflare-workers-ai",
name: "Cloudflare Workers AI",
modelsDir: "providers/cloudflare-workers-ai/models",
deleteMissing: false,
authoritativeHeaders: true,
async fetchModels() {
const accountID = process.env.CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID;
const token = process.env.CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN;
Expand All @@ -66,50 +96,104 @@ export const cloudflareWorkersAi = {
}

const first = await fetchPage(accountID, token, 1);
const models = parseCloudflareModels(first);
const pageInfo = CloudflareOpenRouterResponse.safeParse(first).success
? CloudflareOpenRouterResponse.parse(first).result_info
: undefined;

for (let page = 2; page <= (pageInfo?.total_pages ?? 1); page++) {
models.push(...parseCloudflareModels(await fetchPage(accountID, token, page)));
if (first === undefined) throw new Error("Cloudflare Workers AI search returned no usable models");
const rows = modelRows(first);
for (let page = 2; page <= (first.result_info?.total_pages ?? 1); page++) {
const response = await fetchPage(accountID, token, page);
// Keep successful pages; deleteMissing: false retains models on failed pages.
if (response !== undefined) rows.push(...modelRows(response));
}

return { data: models };
return { data: rows };
},
parseModels(raw) {
return parseCloudflareModels(raw);
const models = parseCloudflareModels(raw);
if (models.length === 0) throw new Error("Cloudflare Workers AI search returned no usable models");
return models;
},
translateModel(model, context) {
const normalized = normalizeModel(model);
const id = normalized.id.replace(/^workers-ai\//, "");
const id = model.id;
const existing = context.existing(id);
if (existing === undefined && hasReasoning(model) && workersAiReasoningOptions(model) === undefined) {
throw new MissingReasoningOptionsError(id, "Workers AI Search does not specify concrete reasoning controls; manual authoring is needed");
}
const translated = buildWorkersAiModel(model, existing);
// Only read paths already present in the sync runner's catalogue map.
const header = existing === undefined ? "" : modelHeader(path.resolve(ROOT_DIR, this.modelsDir, `${id}.toml`));
if (header === undefined) return undefined;
// Remove only the exact generated line; preserve every other leading comment.
const preservedHeader = header.replace(TOGGLE_HEADER, "");
// Respect curated model-specific wire instructions, including Qwen's split lines.
const hasToggleWire = /\b(?:thinking\.type|chat_template_kwargs\.thinking|enable_thinking)\b/.test(preservedHeader);
const toggleHeader = ENABLE_THINKING_MODELS.has(id) ? TOGGLE_HEADER : "";
if (translated.reasoning_options?.some((option) => option.type === "toggle") && !hasToggleWire && !toggleHeader) {
if (existing === undefined) {
throw new MissingReasoningOptionsError(id, "Workers AI toggle wire path needs manual verification");
}
console.warn(`Keeping catalogue reasoning for ${id}: Workers AI toggle wire path is unknown`);
// Keep the reasoning boundary small: other usable properties still update.
return {
id,
model: buildWorkersAiModel({ ...model, reasoning: undefined }, existing),
header,
};
}
return {
id,
model: buildWorkersAiModel(normalized, context.existing(id)),
model: translated,
header: (translated.reasoning_options?.some((option) => option.type === "toggle") && !hasToggleWire ? toggleHeader : "")
+ preservedHeader,
};
},
} satisfies SyncProvider<CloudflareModel>;
} satisfies SyncProvider<WorkersAiModel>;

function hasReasoning(model: WorkersAiModel) {
return model.supported_parameters.includes("reasoning") || model.supported_parameters.includes("include_reasoning")
|| model.reasoning?.mandatory !== undefined || model.reasoning?.supported_efforts !== undefined
|| model.reasoning?.supports_max_tokens === true;
}

function modelHeader(file: string) {
try {
const lines = readFileSync(file, "utf8").split("\n");
const firstKey = lines.findIndex((line) => line.trim() !== "" && !line.trim().startsWith("#"));
return lines.slice(0, firstKey === -1 ? lines.length : firstKey).join("\n") + "\n";
} catch (error) {
console.warn(`Skipping Workers AI model with an unreadable catalogue header: ${file}`, error);
return undefined;
}
}

export function buildWorkersAiModel(
model: z.infer<typeof OpenRouterModel>,
model: WorkersAiModel,
existing: ExistingModel | undefined,
): SyncedModel {
const reasoningOptions = workersAiReasoningOptions(model);
const reasoning = reasoningOptions !== undefined
? true
: existing?.reasoning ?? hasReasoning(model);
const source = {
...model,
reasoning: undefined,
supported_parameters: [
...model.supported_parameters.filter((parameter) => !["reasoning", "include_reasoning"].includes(parameter)),
...(reasoning ? ["reasoning"] : []),
],
name: existing?.name ?? model.name,
top_provider: {
...model.top_provider,
max_completion_tokens: existing?.limit?.output ?? model.top_provider.max_completion_tokens,
},
};
const synced = {
...buildOpenRouterModel(
source,
existing,
existing?.base_model ?? resolveCloudflareBaseModel(model),
),
reasoning_options: existing?.reasoning_options,
};
// The shared builder uses these options when source.reasoning is omitted.
const existingWithReasoningOptions = reasoningOptions === undefined
? existing
: { ...existing, reasoning_options: reasoningOptions };
const synced = buildOpenRouterModel(
source,
existingWithReasoningOptions,
existing?.base_model ?? resolveCloudflareBaseModel(model),
);
if ("base_model" in synced) return synced;
return {
...synced,
Expand All @@ -123,7 +207,50 @@ export function buildWorkersAiModel(
};
}

export function resolveCloudflareBaseModel(model: z.infer<typeof OpenRouterModel>) {
function workersAiReasoningOptions({ id, reasoning }: WorkersAiModel): SyncedFullModel["reasoning_options"] {
// A null gateway allowlist does not identify concrete model controls.
// Preserve the catalogue instead of expanding it to every effort in the schema.
if (reasoning === undefined || reasoning.supported_efforts === null) return undefined;

const options: NonNullable<SyncedFullModel["reasoning_options"]> = [];
const efforts = reasoning.supported_efforts;
if (efforts?.length === 0) return undefined;
if (efforts === undefined && reasoning.supports_max_tokens !== true) {
// GLM-4.7-Flash's verified ConfigAPI/Search shape is { mandatory: false,
// default_enabled: true }; its only control is enable_thinking. The generic
// input schema's low/medium/high enum is not a model capability.
// Creator: https://huggingface.co/zai-org/GLM-4.7-Flash/blob/main/chat_template.jinja
// Require the explicit off-capability flag; absence of efforts alone says nothing.
return id === "@cf/zai-org/glm-4.7-flash" && reasoning.mandatory === false
? [{ type: "toggle" }]
: undefined;
}
// Without either an explicit mandatory flag or a named off setting, a partial
// effort list cannot tell us whether replacing the catalogue would lose a toggle.
if (reasoning.mandatory === undefined && !efforts?.includes("none")) return undefined;

if (reasoning.mandatory === false && !efforts?.includes("none")) {
options.push({ type: "toggle" });
}

const values = reasoning.mandatory ? efforts?.filter((value) => value !== "none") : efforts;
// One mandatory effective effort offers no caller choice. Unlike missing or
// empty metadata, a concrete singleton establishes this explicitly.
const fixedEffort = reasoning.mandatory === true && values?.length === 1;
if (values?.length && !fixedEffort) {
options.push({ type: "effort", values: [...values] });
}

if (reasoning.supports_max_tokens === true) {
// Explicit reasoning.max_tokens support, never inferred from output limits.
options.push({ type: "budget_tokens" });
}

// Empty control metadata never clears authored controls; a known fixed effort can.
return options.length > 0 || fixedEffort ? options : undefined;
}

export function resolveCloudflareBaseModel(model: WorkersAiModel) {
const [, publisher] = model.id.replace(/^workers-ai\//, "").split("/");
if (publisher === undefined) return undefined;

Expand Down Expand Up @@ -157,39 +284,61 @@ async function fetchPage(accountID: string, token: string, page: number) {
url.searchParams.set("per_page", "1000");
url.searchParams.set("page", String(page));

const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(
`Cloudflare Workers AI models request failed: ${response.status} ${response.statusText}${await responseDetails(response)}`,
);
for (let attempt = 1; attempt <= 4; attempt++) {
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}${await responseDetails(response)}`);
}
return CloudflareResponse.parse(await response.json());
} catch (error) {
console.warn(
`Workers AI search page ${page}, attempt ${attempt}/4 failed: ${error instanceof Error ? error.message : String(error)}`,
);
if (attempt < 4) await Bun.sleep(30_000);
}
}
return response.json();
}

function parseCloudflareModels(raw: unknown): CloudflareModel[] {
const cloudflare = CloudflareResponse.safeParse(raw);
if (cloudflare.success) return cloudflare.data.data;

const direct = OpenRouterResponse.safeParse(raw);
if (direct.success) return direct.data.data.map((model) => CloudflareModel.parse(model));

const wrapped = CloudflareOpenRouterResponse.parse(raw);
if (wrapped.result === undefined) {
throw new Error("Cloudflare Workers AI response did not include model data");
}
const models = Array.isArray(wrapped.result) ? wrapped.result : wrapped.result.data;
return models.map((model) => CloudflareModel.parse(model));
function parseCloudflareModels(raw: unknown): WorkersAiModel[] {
const response = CloudflareResponse.parse(raw);
return modelRows(response).flatMap((row) => {
const parsed = CloudflareModel.safeParse(row);
if (!parsed.success) {
console.warn(`Skipping invalid Workers AI model: ${parsed.error.message}`);
return [];
}
try {
return [normalizeModel(parsed.data)];
} catch (error) {
console.warn(`Skipping invalid Workers AI model ${parsed.data.id}: ${error instanceof Error ? error.message : String(error)}`);
return [];
}
});
}

function normalizeModel(model: CloudflareModel) {
if ("architecture" in model && "top_provider" in model && "supported_parameters" in model) {
return OpenRouterModel.parse(model);
const parsed = WorkersAiReasoning.safeParse(model.reasoning);
const reasoning = parsed.success ? parsed.data : undefined;
if (!parsed.success && model.reasoning != null) {
console.warn(`Ignoring invalid Workers AI reasoning for ${model.id}: ${parsed.error.message}`);
}
const id = model.id.replace(/^workers-ai\//, "");
const normalizedID = id.startsWith("@cf/") ? id : `@cf/${id}`;
if ("architecture" in model || "top_provider" in model || "supported_parameters" in model) {
const normalized = WorkersAiModel.parse({ ...model, id: normalizedID, reasoning });
z.number().int().positive().nullable().parse(normalized.top_provider.max_completion_tokens);
if (normalized.architecture.input_modalities.length === 0 || normalized.architecture.output_modalities.length === 0) {
throw new Error("Model modalities must not be empty");
}
return normalized;
}

return OpenRouterModel.parse({
id: model.id.startsWith("@cf/") ? model.id : `@cf/${model.id.replace(/^@cf\//, "")}`,
return WorkersAiModel.parse({
id: normalizedID,
name: model.name,
created: model.created,
hugging_face_id: model.hugging_face_id ?? null,
Expand All @@ -208,6 +357,7 @@ function normalizeModel(model: CloudflareModel) {
...model.supported_sampling_parameters ?? [],
...model.supported_features ?? [],
],
reasoning,
});
}

Expand Down
Loading