@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
332 lines (331 loc) • 14.5 kB
JavaScript
import { CloudflareClefModels } from "../constants/enums.js";
import { logger } from "../utils/logger.js";
import { redactUrlForError } from "../utils/logSanitize.js";
import { getProviderModel } from "../utils/providerConfig.js";
import { isRecord, redactCredentials, SystemOneDecisionProvider, } from "./systemOneDecision.js";
const CLOUDFLARE_DEFAULT_BASE_URL = "https://api.cloudflare.com/client/v4";
const MODEL_PREFIX = "@cf/cloudflare/";
/**
* Cloudflare account ids are 32 hex digits. The pattern is wider so that a
* change of format needs no release, but it still keeps `/`, `.`, `?` and
* whitespace out of the URL path the id is placed in.
*/
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
/** The only image formats the API reads; anything else is a 422. */
const SUPPORTED_IMAGE_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
/**
* The uuid Workers AI appends to an `AiError` message, in parentheses. It starts
* at a literal `(`, with no leading `\s*`: that one backtracks quadratically on
* a long run of spaces, and the message is whatever the server sent.
*/
const TRAILING_REQUEST_ID = /\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)\s*$/i;
const MAX_ERROR_MESSAGE_CHARS = 500;
/**
* An error message is cut to this before any pattern runs on it, so a hostile or
* garbled body cannot hold the event loop. Cloudflare's own messages are far
* shorter (the longest seen was about 350 characters).
*/
const MAX_PARSED_MESSAGE_CHARS = 4096;
function normalizeBaseURL(raw) {
return raw.replace(/\/+$/, "");
}
/**
* A base URL that cannot work is refused up front, and its text is never
* repeated: `fetch` rejects a URL with userinfo and echoes it in the error, the
* route is appended after a query string or fragment, and any of them can hold
* a credential.
*/
function baseURLProblem(baseURL) {
const fix = `Set CLOUDFLARE_CLEF_BASE_URL or pass credentials.cloudflareClef.baseURL to a base URL, or leave both unset to use ${CLOUDFLARE_DEFAULT_BASE_URL}.`;
try {
const url = new URL(baseURL);
if (url.protocol !== "https:" && url.protocol !== "http:") {
return `The Cloudflare base URL must start with https:// or http://. ${fix}`;
}
// `url.search` and `url.hash` are empty for a bare trailing `?` or `#`, which
// would still put the route into the query or the fragment, so the text is
// checked as well.
return url.username ||
url.password ||
url.search ||
url.hash ||
/[?#]/.test(baseURL)
? `The Cloudflare base URL must not carry credentials, a query string or a fragment. ${fix}`
: undefined;
}
catch {
return `The Cloudflare base URL is not a valid absolute URL. ${fix}`;
}
}
/**
* The name Cloudflare wants in the path and the body: `clef` or `clef-flash`,
* given either bare or as `@cf/cloudflare/clef`. Anything with a `/` in it is
* refused, so a model name cannot reach a different route.
*/
function wireModel(model) {
const bare = model.trim().replace(/^@cf\/cloudflare\//i, "");
return /^[a-z0-9][a-z0-9._-]*$/i.test(bare) ? bare.toLowerCase() : undefined;
}
/**
* Why this image cannot be sent, or undefined when it can. The data URL is
* never repeated: it is megabytes of base64. Size limits are left to the
* server, which refuses an oversized image at once with a clear 422.
*/
function imageProblem(dataUrl, position) {
const type = /^data:([a-z]+\/[a-z0-9.+-]+);base64,/i
.exec(dataUrl)?.[1]
?.toLowerCase();
return type && SUPPORTED_IMAGE_TYPES.has(type)
? undefined
: `Image ${position} is not a PNG, JPEG or WebP image, the only formats Cloudflare Clef reads.`;
}
/** The API takes a string or structured data as `state`; a number or a boolean is sent as its text. */
function wireState(state) {
return typeof state === "number" || typeof state === "boolean"
? String(state)
: state;
}
/**
* `{"success":false,"errors":[{"code","message"}],...}` is Cloudflare's own
* envelope. A model-side refusal nests a second one inside the message:
* `AiError: AiError: {"error":{"message","details":{"fieldErrors":{...}}}} (<uuid>)`.
* Both are flattened to one readable line, with the uuid taken out as the
* request id. A body that is not this envelope reads as undefined and the
* caller falls back to the status.
*/
function readCloudflareError(body) {
if (!isRecord(body) || !Array.isArray(body.errors)) {
return undefined;
}
const first = body.errors.find(isRecord);
if (!first || typeof first.message !== "string" || first.message === "") {
return undefined;
}
let text = first.message.slice(0, MAX_PARSED_MESSAGE_CHARS).trimEnd();
let requestId;
const trailing = TRAILING_REQUEST_ID.exec(text);
if (trailing) {
requestId = trailing[1];
text = text.slice(0, trailing.index).trimEnd();
}
text = text.replace(/^(?:AiError:\s*|Ai:\s*)+/, "").trim();
if (text.startsWith("{")) {
try {
const inner = JSON.parse(text);
if (isRecord(inner) && isRecord(inner.error)) {
const details = isRecord(inner.error.details)
? inner.error.details
: undefined;
const fields = isRecord(details?.fieldErrors)
? Object.entries(details.fieldErrors).flatMap(([field, problems]) => Array.isArray(problems)
? problems
.filter((p) => typeof p === "string")
.map((p) => `${field}: ${p}`)
: [])
: [];
const head = typeof inner.error.message === "string" ? inner.error.message : text;
text = fields.length > 0 ? `${head}: ${fields.join("; ")}` : head;
}
}
catch {
// not JSON after all; keep the text as it is
}
}
return {
message: text,
code: typeof first.code === "number" ? first.code : undefined,
requestId,
};
}
/**
* Only 401 is `authentication`, and so only 401 trips the breaker: Cloudflare
* answered a token it does not know with a 401 and code 10000 (seen live). A 403
* was never seen, and no such token was available to provoke one; if Cloudflare
* uses it for a token that lacks Workers AI permission, that is fixed in the
* dashboard, and a breaker that latched on it would keep this instance disabled
* until the process restarted, even after the permission was granted. So a 403
* is a non-retried `invalid_request` that carries Cloudflare's own message. A
* request over the context window is a 413, code 5021, and a 429 is "Capacity
* temporarily exceeded" (code 3040, no Retry-After), which is retryable.
* Validation refusals are 422, and a malformed body or a wrong route is a 400;
* none of those is retried.
*/
function cloudflareErrorKind(status, code) {
if (status === 401) {
return "authentication";
}
if (status === 413 || code === 5021) {
return "max_tokens_exceeded";
}
if (status === 429) {
return "rate_limit";
}
if (status === 503) {
return "overloaded";
}
return status >= 500 ? "server" : "invalid_request";
}
/**
* Cloudflare Clef — the `decide` inference type only.
*
* `@cf/cloudflare/clef` (27B) and `@cf/cloudflare/clef-flash` (9B) answer the
* same typed `noul` / `choice` / `score` questions as the other decision
* providers, follow the System One wire, and read images. They are reached
* through the Workers AI REST API, which wraps every answer in Cloudflare's own
* `{ result, success, errors }` envelope and puts the model in the URL path.
*
* The token and account id are the ones the Cloudflare Workers AI text provider
* reads (`CLOUDFLARE_API_KEY`, `CLOUDFLARE_ACCOUNT_ID`), so a host that has
* configured that provider has also configured this one.
*
* The Workers AI endpoint ignores text past about 2,048 tokens, far below
* the documented 64K (hosted service or model: unknown). See `decisionLimits`
* on the descriptor for the measured figures.
*
* @see https://developers.cloudflare.com/workers-ai/models/clef/
*/
export class CloudflareClefProvider extends SystemOneDecisionProvider {
apiKey;
accountId;
baseURL;
constructor(modelName, sdk, _region, credentials) {
super(modelName, "cloudflare-clef", sdk);
// A slice that names its own base URL must bring its own token: the shared
// CLOUDFLARE_API_KEY also runs the host's Workers AI text provider, and it
// must never be sent as a bearer token to an endpoint a caller chose.
const ownEndpoint = Boolean(credentials?.baseURL?.trim());
this.apiKey =
credentials?.apiKey?.trim() ||
(ownEndpoint ? "" : (process.env.CLOUDFLARE_API_KEY?.trim() ?? ""));
this.accountId =
credentials?.accountId?.trim() ||
(process.env.CLOUDFLARE_ACCOUNT_ID?.trim() ?? "");
// `||`, not `??`, so a blank override counts as unset.
this.baseURL = normalizeBaseURL(credentials?.baseURL?.trim() ||
process.env.CLOUDFLARE_CLEF_BASE_URL?.trim() ||
CLOUDFLARE_DEFAULT_BASE_URL);
// A base URL that `baseURLProblem` refuses is not logged at all: it can
// carry `user:pass@` or a `?token=`.
logger.debug("Cloudflare Clef decision provider initialized (decide only)", {
modelName: this.modelName,
baseURL: baseURLProblem(this.baseURL)
? "(invalid)"
: redactUrlForError(this.baseURL),
accountConfigured: this.accountId !== "",
});
}
getDefaultModel() {
return getProviderModel("CLOUDFLARE_CLEF_MODEL", CloudflareClefModels.CLEF);
}
vendorLabel() {
return "Cloudflare";
}
vendorDisplayName() {
return "Cloudflare Clef";
}
decisionApiKey() {
return this.apiKey;
}
missingKeyMessage() {
return "Cloudflare Clef requires an API token with Workers AI permission. Set CLOUDFLARE_API_KEY or pass credentials.cloudflareClef.apiKey.";
}
missingConfigMessage() {
if (this.accountId === "") {
return "Cloudflare Clef requires the account id. Set CLOUDFLARE_ACCOUNT_ID or pass credentials.cloudflareClef.accountId.";
}
if (!ACCOUNT_ID_PATTERN.test(this.accountId)) {
return "The Cloudflare account id may contain only letters, digits, '-' and '_'. Copy it from the Cloudflare dashboard.";
}
return baseURLProblem(this.baseURL);
}
/** The model is part of the path, so the endpoint depends on the model asked for. */
decisionEndpoint(model) {
return `${this.baseURL}/accounts/${encodeURIComponent(this.accountId)}/ai/run/${MODEL_PREFIX}${encodeURIComponent(wireModel(model) ?? model)}`;
}
decisionHeaders() {
return {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
}
/**
* Images travel in their own `images` array, as `data:` URLs, placed before
* the state by the server. `model` is sent although the path already names
* it: the API documents it as required, and refuses a body whose `model`
* differs from the path.
*/
buildDecisionBody(state, questions, model, media) {
const wire = wireModel(model);
if (!wire) {
throw this.decisionError({
kind: "invalid_request",
message: `"${model.slice(0, 40)}" is not a Cloudflare model name. Use "clef" or "clef-flash".`,
retryable: false,
});
}
// Workers AI also accepts image/jpg; use the canonical JPEG MIME type.
const images = (media?.images ?? []).map((dataUrl) => dataUrl.replace(/^data:image\/jpg;base64,/i, "data:image/jpeg;base64,"));
images.forEach((dataUrl, index) => {
const problem = imageProblem(dataUrl, index + 1);
if (problem) {
throw this.decisionError({
kind: "invalid_request",
message: problem,
retryable: false,
});
}
});
// The canonical JPEG prefix is one byte longer than image/jpg. Keep the
// prepared media metadata aligned with the URLs actually sent.
if (media) {
media.images = images;
media.bytes = images.reduce((total, image) => total + Buffer.byteLength(image), 0);
}
return {
model: wire,
state: wireState(state),
questions,
...(images.length > 0 ? { images } : {}),
};
}
/** A success is `{ result: { model, answers, usage }, success: true }`. */
readDecisionPayload(payload) {
return isRecord(payload) && isRecord(payload.result)
? payload.result
: payload;
}
parseDecisionError(status, payload, requestId) {
const fallback = `Cloudflare request failed with HTTP ${status}`;
const parsed = readCloudflareError(payload);
const kind = cloudflareErrorKind(status, parsed?.code);
let message = redactCredentials(parsed?.message ?? fallback, this.apiKey)
.trim()
.slice(0, MAX_ERROR_MESSAGE_CHARS) || fallback;
if (kind === "max_tokens_exceeded") {
message +=
" Cloudflare counts the whole request, image data included, at about four characters per token.";
}
else if (parsed?.code === 7000) {
message += `. Check the model name (Clef is served as "clef" and "clef-flash") and the base URL, which must end in /client/v4 and name your account.`;
}
return {
kind,
message,
status,
requestId: requestId ?? parsed?.requestId,
retryable: kind === "rate_limit" || kind === "overloaded" || kind === "server",
};
}
/**
* `cf-ai-req-id` is on every answer that reached the model, success or
* refusal. A 401 and a wrong-path 400 never reach it, so they carry only the
* edge's `cf-ray`.
*/
readRequestId(headers) {
return headers.get("cf-ai-req-id") ?? headers.get("cf-ray") ?? undefined;
}
}