JavaScript & TypeScript#
Serve a trained shad0w table from JavaScript, in front of your LLM, in Node, the browser or at the edge. Training stays in Python; serving runs anywhere.
npm i shad0wllm
Zero dependencies, ESM and CommonJS, TypeScript types included. It runs in Node 18+, browsers, Cloudflare Workers, Deno and Bun. Decisions are identical to Python and C on the same table.
TL;DR#
import { decision } from "shad0wllm";
const intent = await decision("intent", {
options: { refund: "wants money back", lost_card: "card lost or stolen", balance: "asks about balance", other: "anything else" },
llm: "openai/gpt-6-luna",
apiKey: process.env.OPENAI_API_KEY,
bundle: "shad0w/intent/bundle", // a trained table: a folder in Node, a URL in browsers and workers
log: "shad0w/intent/log.jsonl", // where your LLM's answers go (the training data)
});
const d = await intent.decide("my card was stolen");
// { answer: "lost_card", source: "table", confidence: 0.99, certified: true, flag: null,
// why: "certified: answered by the table", threshold: 0.91, question: "intent", latencyUs: 9.8 }
The table (shad0w's small learned model) answers when it is certified: tested on answers it never trained on, and allowed to answer only where it disagrees with your LLM on at most α of them (α = 5% by default). Otherwise your LLM answers and the answer goes to log. A bundle path that does not exist yet is fine: every call goes to your LLM until you train one.
What a decision contains#
| Field | Type | Meaning |
|---|---|---|
answer |
string or boolean |
the option (a boolean for yes/no questions) |
source |
"table", "teacher" or "fallback" |
who answered: the table, your LLM (logged), or your fallback (not logged) |
certified |
boolean |
the answer came from the table and is covered by the certificate |
confidence |
number or null |
the table's confidence in its top option; null without a table |
threshold |
number or null |
the table's certified threshold |
flag |
string or null |
why the table did not answer: no_bundle, low_confidence, uncalibrated, options_changed, option_removed (all flags) |
why |
string |
the flag in plain words (same wording as Python) |
latencyUs |
number |
the whole call in microseconds, including any LLM call |
question |
string |
the decision's name |
Keys#
llm takes the same "provider/model" names as Python (anthropic/…, groq/…, ollama/llama3.1, …; see Connect your LLM), or a model name plus baseURL for any OpenAI-compatible server.
The key, first match wins:
apiKey: a string, or a function() => key(sync or async) called on every request, so rotation just works;apiKeyEnv: the name of an environment variable (Node);configure({ apiKey }), thenconfigure({ apiKeyEnv });- the provider's usual variable, e.g.
OPENAI_API_KEY(Node).
import { configure, decision } from "shad0wllm";
configure({ llm: "openai/gpt-6-luna", apiKeyEnv: "SUPPORT_BOT_OPENAI_KEY" }); // once, for the whole process
const intent = await decision("intent", { options: ["refund", "lost_card", "other"] });
console.log(String(intent)); // Shadow("intent", options=3, logging (no table yet), llm=openai/gpt-6-luna, key: set via SUPPORT_BOT_OPENAI_KEY (sk-…3f9a))
configure(); // no argument: clear the defaults
The key stays in a closure. It is not a property of the teacher and never appears in errors or logs; String(intent) shows it masked. apiKey, apiKeyEnv or baseURL with a function llm, or a mistyped option (apikey → did you mean "apiKey"?), throw a TypeError.
Train (Python, once)#
pip install "shad0wllm[compile]"
shad0w train --log shad0w/intent/log.jsonl --out shad0w/intent/bundle
The log rows have the same format as Python's, so shad0w train reads logs written by either language. Ship the bundle folder (manifest.json plus one .s0 file per question) with your app, or put it on a CDN.
Serve#
Node: as in the TL;DR. A file path for log appends JSON lines.
Cloudflare Worker: certified answers never leave the edge.
import { decision } from "shad0wllm";
let intent;
export default {
async fetch(req, env) {
intent ??= await decision("intent", {
llm: "openai/gpt-6-luna", apiKey: env.OPENAI_API_KEY,
bundle: env.BUNDLE_URL, // e.g. a static-assets or R2 URL
log: (row) => env.LOG_QUEUE.send(row), // collect LLM answers for the next training run
});
const { text } = await req.json();
return Response.json(await intent.decide(text));
},
};
With a bundle and no options, the options come from the bundle.
Browser: decide as the user types, with no server and no LLM.
import { Bundle } from "https://cdn.jsdelivr.net/npm/shad0wllm@0.3.2/index.mjs";
const bundle = await Bundle.load("/models/intent");
const a = bundle.decide(input.value).answers.intent; // { choice, confidence, certified, flag, probabilities }
if (a.certified) show(a.choice); // otherwise ask your server
Batches#
const results = await intent.decideMany(texts, { concurrency: 8 }); // decisions, in the same order as texts
The table answers first; only the texts it defers go to your LLM, at most concurrency at a time.
Vercel AI SDK#
import { wrapLanguageModel, experimental_decide } from "ai";
import { openai } from "@ai-sdk/openai";
import { decision, shad0wMiddleware, decisionModel } from "shad0wllm";
const intent = await decision("intent", {
options: ["refund", "lost_card", "other"], bundle: "shad0w/intent/bundle", log: "shad0w/intent/log.jsonl",
});
// generateText / generateObject: certified answers skip the model; the model's answers are logged for training.
const model = wrapLanguageModel({ model: openai("gpt-6-luna"), middleware: shad0wMiddleware(intent, { specificationVersion: "v3" }) });
// experimental_decide: the table answers the questions it certifies, the fallback decision model answers the rest.
const result = await experimental_decide({
model: decisionModel(intent, { fallback: openai.decisionModel("gpt-6-luna") }),
state: "my card was stolen",
questions: { intent: { type: "choice", instructions: "What does the customer want?", criteria: { refund: null, lost_card: null, other: null } } },
});
| Option | Default | Meaning |
|---|---|---|
shad0wMiddleware(shadow, { specificationVersion }) |
"v3" |
must match the installed ai major: ai 5 → "v2", ai 6 → "v3", ai 7 → "v4" |
shad0wMiddleware(shadow, { format }) |
the bare option | shapes the reply text of a table answer; for enum output through generateObject, pass (a) => JSON.stringify({ result: a }) |
shad0wMiddleware(shadow, { options }) |
the Shadow's options | the options to find in the model's replies |
decisionModel(shadows, { fallback }) |
none | another decision model for the questions the tables do not certify. Without it, those come back as { type: "refusal" } with a warning. |
decisionModel takes one Shadow or { [question]: Shadow }. More recipes: Frameworks.
Decision models as the teacher#
const intent = await decision("intent", {
options: { lost_card: "card lost or stolen", refund: "wants money back", other: "anything else" },
llm: "systemone/kev", baseURL: "http://127.0.0.1:8081", // any /v1/systemone server: Jev, Kev, Laya, llama.cpp
// llm: "openai-decisions/gpt-6-luna", // the OpenAI Decisions API (reads OPENAI_API_KEY)
});
See Decisions API & System One for running an open decision model locally.
API#
decision(name?, opts) → Promise<Shadow>#
name defaults to "decision"; decision(opts) with opts.name works too.
| Option | Type | Default | What it does |
|---|---|---|---|
options |
string[] or { name: description } |
the bundle's | the possible answers; needed with a "provider/model" llm until a bundle exists |
llm |
string or async (text) => answer |
configure({ llm }) |
"provider/model" or your own function |
apiKey, apiKeyEnv |
see Keys | the key, or the name of the variable that holds it | |
baseURL |
string |
configure({ baseURL }), then SHAD0W_BASE_URL, then the provider's |
an OpenAI-compatible server |
bundle |
path, URL or Bundle |
none | the trained table. Missing = log-only; corrupt or unsupported = throws. |
log |
path (Node), (row) => void or a stream |
none | where your LLM's answers go. Without it nothing is logged. |
fallback |
value or (text) => answer |
none | the answer when the table defers and there is no llm (source: "fallback", never logged) |
auditRate |
number |
0.01 |
share of answers served by the table that are re-asked to the LLM in the background (spot checks) |
onDecision |
(d, text) => void |
none | called after every decision |
rename |
{ old: new } |
{} |
rename labels without retraining (Changing your options) |
onNewOption |
"defer" or "serve" |
"defer" |
options added since training: defer sends everything to the LLM until you retrain |
system, temperature, maxTokens, retries, headers, structured |
built from options, 0, 50, 2, {}, true |
the LLM request, as in Python | |
timeout |
number (ms) |
30000 |
per attempt |
fetch |
typeof fetch |
globalThis.fetch |
a custom fetch |
Shadow#
new Shadow(bundle | null, { teacher, question, options, log, fallback, rename, onNewOption, auditRate, onDecision }) builds one directly. With options and a bundle, options the bundle never learned send every decision to the teacher until you retrain, and removed ones are never served.
| Method | Returns | What it does |
|---|---|---|
await s.decide(text, { teacher }) |
decision | the table's answer when certified, otherwise the teacher's (logged) |
await s.decideMany(texts, { concurrency: 8 }) |
decisions | a batch, in order (above) |
await s.peek(text) |
decision or null |
the table's decision if it would serve it; never calls the teacher |
await s.record(text, answer) |
decision | log an answer you got from your model yourself |
s.explain(text) |
{ text, answer, confidence, threshold, certified, flag, why, top } |
what the table thinks, without calling your model |
s.stats() |
{ table, teacher, fallback, offload, audits, auditDisagreements, auditDisagreement, optionsAdded, optionsRemoved } |
counters |
s.options(), s.threshold() |
the question's options and certified threshold | |
String(s) |
string |
question, options, state, LLM and where its key comes from (masked) |
Lower level#
| Export | What it does |
|---|---|
configure({ llm, apiKey, apiKeyEnv, baseURL }) |
process-wide defaults; configure() clears them; returns them with the key masked |
openaiTeacher({ options, model, baseURL, apiKey, apiKeyEnv, ... }) |
async (text) => option for any OpenAI-compatible chat model: structured output with a plain-text fallback, retries with backoff, every reply mapped to exactly one option (or an error) |
systemoneTeacher({ options, model, baseURL, apiKey }) |
the same, asking a System One server |
decisionsTeacher({ options, model, baseURL, apiKey, apiKeyEnv }) |
the same, asking the OpenAI Decisions API |
Bundle.load(dirOrUrl) |
load a trained table |
bundle.decide(text, { questions, probabilities }) |
{ answers: { [name]: answer } }, where each answer has choice (or answer for yes/no), confidence, certified, flag and probabilities (or probability). probabilities: false is the fast path: the same answer, confidence and certified flag without the per-option map. |
new Table(arrayBuffer, labels).decide(text) |
one .s0 file; { index, choice, confidence, probabilities } |
matchOption(reply, options) |
the reply-to-option mapping the teachers use |
explainFlag(flag), FLAG_WORDS |
the reason behind a flag, in plain words |
maskKey(key) |
"sk-…3f9a" |
What is Python-only#
Training, the rollout settings (mode, canary, never_serve, min_confidence), the drift guard and exposed mode are Python-only. A table trained and certified in Python serves the same answers in JavaScript; its certified flag is based on the threshold alone.