Skip to content

ai

import { runAi, runAiText, generateAltText, rewriteText, suggestSeo } from "louise-toolkit/ai";

Optional Workers AI editorial assists and semantic search. Every helper degrades gracefully: with no env.AI binding, or on any model error, it returns null / []—a save, upload, or publish is never blocked or broken by AI. The binding is passed in and the model id is a plain string, so the module is catalog-agnostic. Binding: AI (+ VECTORIZE for search). No required peers. See the AI assists guide.

Every generation helper here returns plain text, never HTML, so none of their output needs an HTML sanitizer. If your own code asks a model for HTML, run it through sanitizeModelHtml before you store it.

function aiRunner(env: unknown): AiRunner | undefined;
function aiGenerationDisabled(env: unknown): boolean;
function aiUnavailableReason(env: unknown): "disabled" | "unconfigured";

Binding presence is a real switch, and for “this site never uses AI” it is arguably the right one—nothing to configure, nothing to drift. It stops working the moment you want to keep the binding and still disable generation: an embeddings-backed search that must keep running while alt-text and SEO suggestions go quiet, a client whose contract forbids generated copy, or a temporary kill after a bad model swap.

Set LOUISE_AI in your vars and redeploy:

{ "vars": { "LOUISE_AI": "off" } }

Then wire the accessor through aiRunner rather than reading the binding directly:

aiRoute({ resolveEditor, ai: aiRunner });
seoFixRoute({ table: pages, resolveEditor, ai: aiRunner });
mediaRoute({ /* … */ altText: aiRunner });

Astroid-generated workers do this for you.

All three take unknown rather than a typed env. They are passed as a route’s accessor, whose parameter is that route’s own Env—and a parameter typed { AI?, LOUISE_AI? } shares no properties with an EditorRouteEnv, so TypeScript rejects the assignment outright. Describing the env precisely would make the helper unusable in the only position it exists for.

Three things worth knowing.

It cannot turn AI on. LOUISE_AI only ever subtracts—with no binding there is nothing to enable. That keeps the env var a ceiling rather than a second source of truth about whether AI works.

Embeddings are deliberately not gated by it. Semantic search generates no content, and folding it in would mean disabling “AI content” silently breaks search—a consequence nobody predicts from the flag’s name, and one that surfaces as “search returns nothing” long after the flag was flipped. Keep vector.ai on the binding:

vector: { index: (env) => env.VECTORIZE, ai: (env) => env.AI }

A site that genuinely wants everything off can still unprovision the binding.

off, false, 0, no, and disabled all mean off, case- and space-insensitively. There is no matching leniency in the other direction: every other value (including unset) means on, so no typo can accidentally disable AI—only spell “off” correctly in more than one way. The failure this avoids is a kill switch that silently doesn’t engage.

Both “off by choice” and “never configured” answer 503, which the client reads as “hide the control”. The response carries reason so the two can be told apart—right for an unprovisioned binding, where there is nothing to tell the editor about, and honest for a deliberate opt-out, where the answer is “AI assists are turned off for this site”.

function runAi(
runner: AiRunner | undefined,
model: string,
inputs: Record<string, unknown>,
options?: Record<string, unknown>,
): Promise<unknown | null>;

The low-level call: runs a model best-effort and returns its raw output, or null when runner is absent or the call throws (never throws—it reports the cause with reportDegraded as ai.run, so it shows in wrangler tail). env.AI satisfies AiRunner structurally—pass it directly. AiGatewayOptions ({ id, cacheKey?, cacheTtl?, skipCache? }) routes a call through AI Gateway for response caching, cost caps, fallbacks, and logging.

runAiText(runner, model, inputs, options?)

Section titled “runAiText(runner, model, inputs, options?)”
function runAiText(
runner: AiRunner | undefined,
model: string,
inputs: Record<string, unknown>,
options?: Record<string, unknown>,
): Promise<AiTextResult | null>;
interface AiTextResult {
output: unknown; // the raw model output
text: string | null;
truncated: boolean;
finishReason: string | null;
usage: AiUsage | null;
}
interface AiUsage {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
}

runAi for text generation: the same null on a missing runner or a thrown error, plus whether the output token cap cut the answer off. The helpers below use it, and it’s the call to reach for when you generate text yourself.

An answer is truncated when either is true:

  • The model reports a finish reason of length or max_tokens, as finish_reason or stop_reason, at the top level or on the first of choices.
  • The model generated at least the max_tokens in inputs, read from usage.completion_tokens or usage.output_tokens.

Workers AI doesn’t always report a finish reason, so the token count is the check that works on every model that reports usage. A model that reports neither can’t be checked, and reads as not truncated. A truncated answer is reported with reportDegraded as ai.truncated, with the model ID, its finish reason, and the tokens it generated against the cap, so it shows in wrangler tail and an onDegraded listener hears it.

Check truncated before you store or show the text:

const out = await runAiText(env.AI, model, { messages, max_tokens: 256 });
if (!out?.text || out.truncated) return null; // keep what you had
function generateAltText(
runner,
image: ArrayBuffer | Uint8Array | number[],
opts?: AltTextOptions,
): Promise<string | null>;

Generate concise alt text for an image via a vision model (DEFAULT_ALT_TEXT_MODEL). The result is tidied—whitespace-collapsed, “an image of…” lead-ins stripped, sentence-cased, and capped at MAX_ALT_TEXT_LENGTH (240) chars. null when the runner is absent, the model errors, it yields no text, or the output cap cut its answer off; the caller keeps its empty-alt fallback. A cut-off caption is never returned, because tidying it would make half a sentence look finished.

function rewriteText(runner, text: string, opts?: RewriteOptions): Promise<string | null>;
type RewriteMode = "tighten" | "rephrase" | "simplify" | "fix";

Rewrite a passage via an instruct model (DEFAULT_TEXT_MODEL), transforming it per opts.mode (default "tighten"). The reply is stripped of wrapping quotes and any “Here is the rewrite:” preamble. REWRITE_MODES lists the four modes in menu order (for a toolbar). null when the runner is absent, the input is blank, the model returns nothing, or the output cap cut its answer off—the caller keeps the original text rather than swapping in a fragment.

The output cap defaults to REWRITE_MAX_TOKENS (512), and a passage longer than REWRITE_MAX_CHARS (1,536 characters, three per token of the cap) is likely to come back cut off. aiRoute refuses a longer selection with a 413; if you call rewriteText yourself, bound the input the same way, or raise maxTokens with it.

const tighter = await rewriteText(env.AI, draft, { mode: "tighten" });
function suggestSeo(runner, content: string, opts?: SeoOptions): Promise<SeoSuggestion | null>;
interface SeoSuggestion {
title: string | null;
description: string | null;
}

Suggest an SEO title + meta description from page content, using Workers AI JSON mode to force a { title, description } object. Fields are length-capped (SEO_TITLE_MAX 60, SEO_DESCRIPTION_MAX 155, the same limits louise-toolkit/seo uses); a missing field becomes null, and a result with neither is null overall. null when the runner is absent, the content is blank, the output cap cut the reply off, or the reply can’t be parsed.

import { embed, indexContent, semanticSearch, removeContentVector } from "louise-toolkit/ai";
function embed(runner, text, opts?): Promise<number[] | null>;
function indexContent(index, runner, namespace, id, text, opts?): Promise<boolean>;
function semanticSearch(
index,
runner,
namespace,
query,
opts?,
): Promise<{ id: number; score: number }[]>;
function removeContentVector(index, namespace, id): Promise<void>;

Embeddings + a Cloudflare Vectorize index, sitting alongside the keyword (D1 FTS5) layer: keywords find tokens, embeddings find intent. embed turns text into a dense vector (DEFAULT_EMBEDDING_MODEL—768-dim bge-base-en-v1.5; create the index with matching --dimensions=768 --metric=cosine). indexContent embeds and upserts a content row under its namespace:id; semanticSearch embeds a query and returns the nearest row ids + scores; removeContentVector drops a row’s vector on unpublish/delete. All best-effort: no binding → embed/search return null/[] so the FTS path carries the query unchanged, never on a write’s critical path.

import { embedMany, indexContents, EMBED_MANY_BATCH } from "louise-toolkit/ai";
function embedMany(runner, texts, opts?): Promise<(number[] | null)[]>;
function indexContents(
index,
runner,
namespace,
items: { id: number; text: string; metadata?: Record<string, VectorMetadataValue> }[],
opts?,
): Promise<number[]>;

embedMany returns the vector embed would give each text, one entry per input, in order: the vector, or null for a blank text or a failed call, so you can retry just the null entries. indexContents is the batch form of indexContent, for a backfill: it embeds with embedMany, upserts the vectors, and returns the row IDs it stored. Use embed and indexContent for one row at a time, such as on publish.

Batching needs CLS pooling. Workers AI’s BGE models pool token vectors by their mean unless you ask otherwise, and under mean pooling a batch changes each text’s vector: a short text batched with a longer one comes back measurably different, and matches its queries less well. So embedMany sends one text per call by default. Pass pooling: "cls", the pooling BGE was trained with, and it sends batchSize texts per call (default EMBED_MANY_BATCH, 100, the most the BGE models take per request), with the same vectors as one at a time.

Mean and CLS vectors don’t mix, so pass the same pooling to every call that writes to an index and to semanticSearch on it. To move an existing index to CLS, re-embed every row with indexContents and pooling: "cls", then switch your searches.

semanticSearch takes an optional minScore: a match scored below it is dropped, so a query that matches nothing returns [] rather than the topK least-distant records. On a cosine index the score is a similarity, where higher is closer. It has no default, because a useful floor depends on the model and the content; measure one against real queries before you set it.

import { fuseRankings, RRF_K } from "louise-toolkit/ai";
function fuseRankings<Id extends string | number>(
lists: readonly { ids: readonly Id[]; weight?: number }[],
options?: { k?: number; limit?: number; boost?: (id: Id) => number },
): Id[];

Merge ranked ID lists with Reciprocal Rank Fusion (RRF), the merge searchRoute uses for keyword and semantic results. An ID scores weight / (k + rank) in each list it appears in, summed, so a list’s native scores never need to be comparable with another’s. IDs can be numbers or strings.

  • weight scales one list’s contribution. The default is 1.
  • k is the rank-damping constant, RRF_K (60) by default. A smaller k favors top ranks more.
  • boost multiplies each ID’s fused score, for a signal that belongs to the document rather than a list, such as an authority weight. It runs before limit.
  • limit caps how many IDs come back.

Equal scores order by ID, so the result is deterministic. A repeated ID in one list counts only at its first position. A negative or non-finite k, weight, or boost result, or a limit that isn’t a non-negative integer, throws a RangeError.

const top = fuseRankings([{ ids: keywordSlugs }, { ids: semanticSlugs, weight: 0.8 }], {
boost: (slug) => authority.get(slug) ?? 1,
limit: 10,
});

AiRunner, AiGatewayOptions, AiTextResult, AiUsage, AltTextOptions, RewriteMode, RewriteOptions, SeoSuggestion, SeoOptions, EmbedOptions, EmbeddingPooling, VectorIndex, VectorRecord, VectorMatch, IndexContentOptions, SemanticSearchOptions, RankedList, FuseRankingsOptions. Constants: DEFAULT_ALT_TEXT_MODEL, MAX_ALT_TEXT_LENGTH, DEFAULT_TEXT_MODEL, REWRITE_MODES, REWRITE_MAX_TOKENS, REWRITE_MAX_CHARS, SEO_TITLE_MAX, SEO_DESCRIPTION_MAX, DEFAULT_EMBEDDING_MODEL, RRF_K. contentVectorId / parseContentVectorId compose and recover the vector id.