Deep-dive: generate, stream, decide: a third inference type for NeuroLink — why this shipped as a provider rather than a subsystem, the five call sites, and the four transport bugs found by adding the Vercel AI Gateway (two of which produced plausible output).
NeuroLink recognises three inference types. Two of them produce text:
| Type | Call | Produces |
|---|---|---|
generate |
neurolink.generate() |
text |
stream |
neurolink.stream() |
text, incrementally |
decide |
neurolink.decide() |
typed judgements — no text |
A decision model takes one state plus a map of named, typed questions and
returns one typed answer per question, all evaluated in a single parallel pass.
There is no text anywhere in the response, so nothing has to be parsed back out
of prose. The decide providers are TypeSafe's Jev, Convai Innovations'
open-weights Laya, Juspay's open-weights XOR, which also reads images and
video, Subquadratic's hosted SubQ Snap, Perplexity's hosted Decisions
API, which also reads images,
Cloudflare's Clef models on Workers AI,
which also read images, and OpenAI's
Decisions API (a public beta), which also reads images.
This is not
neurolink.evaluate(), which scores an already-generated response with RAGAS scorers. Different feature, different word.
| Type | Question | Answer fields |
|---|---|---|
boolean |
Is this statement true? | probability (0–1) — no confidence |
choice |
Which option from this set? | choice, probabilities, confidence |
score |
Rate against an ordered rubric | score, legend, probabilities, confidence |
All three mix freely in one call.
Vocabulary note. TypeSafe calls the yes/no primitive a noul and answers
it in a field of the same name. The Vercel AI SDK and Pydantic AI both renamed
that to boolean/probability when exposing it, and NeuroLink follows them —
the vendor's spelling is translated inside TypeSafeProvider, so another
decision provider slots in without changing any call site.
probabilities says what the model thinks. confidence says whether you
should act on it. On TypeSafe's Jev it is calibrated — derived from the
distribution, not self-reported — which is what makes it usable as a gate. Each
provider reports its own: Perplexity's is, in Perplexity's words, the model's own
certainty estimate and not the top probability, Perplexity does not call it
calibrated, and NeuroLink has not measured whether it is, so tune a threshold on
your own data (see the
Perplexity guide).
Calibration is a property of groups of answers, not a promise about any one. Where a confidence is calibrated, answers scored 0.8 are right about 80% of the time across many answers. It does not mean a specific 0.8 answer is right.
A boolean carries no confidence of its own. Use decisionBooleanConfidence(p)
— distance from a coin flip, so 0.5 → 0 and 0/1 → 1. Note also that a boolean
and an equivalent two-option choice are not guaranteed to agree, and
complementary booleans do not reliably sum to 1, so a threshold tuned on one
question shape does not transfer to another.
export TYPESAFE_API_KEY=apikey_... # enables TypeSafe
export TYPESAFE_MODEL=jev-latest # optional
export TYPESAFE_BASE_URL=https://api.typesafe.ai # optionalGet a key at console.typesafe.ai/keys.
export LAYA_BASE_URL=https://your-proxy.example.com/laya # required: any server exposing <base>/predict
export LAYA_API_KEY=sk-... # the key that endpoint accepts
export LAYA_MODEL=typed-decisions # optional: english | multilingual | typed-decisions | autoLaya has no built-in endpoint. The base URL and key can equally come from the
config passed to the SDK, new NeuroLink({ credentials: { laya: { baseURL, apiKey } } }),
or per call; config set there counts when NeuroLink picks the default decision
provider, exactly as the environment does.
When a TypeSafe key (TYPESAFE_API_KEY or AI_GATEWAY_API_KEY) is configured
alongside Laya, TypeSafe is the default; Laya runs where a caller names it
(provider: "laya") or when neither TypeSafe key is set. Laya counts as
configured only with both its key and its base URL. See the
Laya provider guide.
export XOR_BASE_URL=https://your-proxy.example.com # required: a deployment, or a proxy route to one
export XOR_API_KEY=sk-... # the key that endpoint accepts
export XOR_MODEL=xor-1.1 # optionalXOR, Juspay's open-weights decision model, has no built-in endpoint either.
NeuroLink calls <base URL>/v1/systemone, and a trailing /v1 on the base URL
is accepted. The base URL and key can equally come from the config passed to the
SDK, new NeuroLink({ credentials: { xor: { baseURL, apiKey } } }), or per
call; config set there counts when NeuroLink picks the default decision
provider, exactly as the environment does. On a LiteLLM proxy the key's team
must allow xor-1.1, otherwise the proxy answers 403 team_model_access_denied.
Built-in features use the first configured decision provider in the order
TypeSafe, Laya, XOR, SubQ, Perplexity, Cloudflare Clef, OpenAI Decisions. XOR counts as configured only with both its key
and its base URL, and runs where a caller names it (provider: "xor") or when
neither TypeSafe nor Laya is configured. It reads images and video; see
Images and video and the
XOR provider guide.
export SUBQ_API_KEY=... # required; from console.subq.ai, which shows it once
export SUBQ_MODEL=subq-snap-preview # optional; the only model on the Decisions endpoint
export SUBQ_BASE_URL=https://api.subq.ai # optional; an origin, without /v1Subquadratic's subq-snap-preview (public preview) speaks the same System One
wire as TypeSafe, at a public endpoint, so a key alone configures it and there
is no base URL to set. The key and base URL can equally come from the config
passed to the SDK, new NeuroLink({ credentials: { subq: { apiKey, baseURL } } }),
or per call. The provider id is subq; subquadratic and subq-snap name it
too. It comes after TypeSafe, Laya and XOR and ahead of Perplexity and Cloudflare
Clef: its key belongs to no other provider, so it is set on purpose, whereas
theirs are shared with text providers and must not displace it. NeuroLink sends it no images or video: SubQ's image format is not
one the decide catalog can describe yet, so an image attached to a call is
refused before any request. The state window NeuroLink reports for it is advisory
(reported, never enforced, not a guarantee); see the
SubQ provider guide.
export PERPLEXITY_API_KEY=pplx-... # required; also the Sonar text provider's key
export PERPLEXITY_DECIDER_MODEL=pplx-decider-v1-27b # optional
export PERPLEXITY_DECIDER_BASE_URL=https://api.perplexity.ai # optional: an originPerplexity's pplx-decider-v1-27b is a hosted API at a public endpoint, so a key
alone configures it and there is no base URL to set. NeuroLink calls
<base URL>/v1/decisions; a trailing /v1 on an override is accepted. The key
can equally come from the config passed to the SDK,
new NeuroLink({ credentials: { perplexityDecider: { apiKey } } }), or per call;
config set there counts when a decide() call picks the default decision
provider, exactly as the environment does. The classifier router's auto gate
counts them too, per-call credentials included, so a
credentials.perplexityDecider passed on one call can make auto pick jev.
The provider id is perplexity-decider: the perplexity provider is the
Sonar text provider, which serves generate() and stream() and not decide().
PERPLEXITY_API_KEY is shared with that text provider. A host that set it
only to use Sonar has therefore also configured decide, and built-in features
will use Perplexity whenever none of TypeSafe, Laya, XOR or SubQ is configured.
Perplexity comes after XOR and SubQ in descriptor order, so it never displaces
one that is. Context compaction's relevance stage and summary gate need no opt-in of their
own, so earlier conversation text starts going to Perplexity the first time a
conversation outgrows its budget; model routing, tool routing and RAG planning do
nothing unless enabled. To keep the shared key from activating decisions, pass
the text provider's key as credentials.perplexity instead of through the
environment, and keep it out of .env too, because the SDK and the CLI load that
file into the environment, or configure one of TypeSafe, Laya, XOR and SubQ, which then
receives those texts instead. The Perplexity provider guide lists
what each consumer sends
and the
exact switches.
It reads images but no video; see Images and video.
export CLOUDFLARE_API_KEY=... # required; a Workers AI token, also the Workers AI text provider's key
export CLOUDFLARE_ACCOUNT_ID=... # required; also the Workers AI text provider's account id
export CLOUDFLARE_CLEF_MODEL=clef-flash # optional: clef (27B, the default) or clef-flash (9B)
export CLOUDFLARE_CLEF_BASE_URL=https://api.cloudflare.com/client/v4 # optionalCloudflare's clef and clef-flash are hosted on Workers AI at a public
endpoint, so there is no base URL to set. The account id is part of the route, so
Clef counts as configured only with both the token and the account id. They can
equally come from the config passed to the SDK,
new NeuroLink({ credentials: { cloudflareClef: { apiKey, accountId } } })
(baseURL is optional), or per call; config set there counts when a decide()
call picks the default decision provider, exactly as the environment does. The
classifier router's auto gate counts them too, per-call credentials included,
so a credentials.cloudflareClef passed on one call can make auto pick jev.
The provider id is cloudflare-clef: the cloudflare provider is the Workers AI text
provider, which serves generate() and stream() and not decide(), and
credentials.cloudflare does not configure decide.
The Workers AI endpoint's state window changed during October 2026.
Cloudflare documents a 64K window; until 2026-10-04 text past about 2,048 tokens
was ignored without an error, and on 2026-10-07 states of at least 190,153 tokens
(clef-flash) and 52,154 (clef) were read in full (hosted service or model:
unknown). NeuroLink refuses a state it estimates at more than 16,000 tokens with
max_tokens_exceeded, which every built-in consumer treats as "carry on as
before"; see Limits and gotchas. Latency grows with the
state, about 0.2 s per 1,000 tokens on large ones, so NeuroLink's default timeout
adds 250 ms per 1,000 estimated tokens.
CLOUDFLARE_API_KEY and CLOUDFLARE_ACCOUNT_ID are shared with that text
provider. A host that set them only to use Workers AI text has therefore also
configured decide, and built-in features will use Clef whenever none of
TypeSafe, Laya, XOR, SubQ or Perplexity is configured. Clef comes after
TypeSafe, Laya, XOR, SubQ and Perplexity, so it never displaces one that is. No switch turns it off while the two
variables are set. The Clef provider guide explains
when NeuroLink uses it
and
how the two providers share the token.
It reads images but no video; see Images and video.
export OPENAI_API_KEY=sk-... # required; also the OpenAI chat provider's key
export OPENAI_DECISIONS_MODEL=gpt-6-luna # optional
export OPENAI_DECISIONS_BASE_URL=https://api.openai.com # optional: an originOpenAI's Decisions API is a public beta at a public endpoint, so a key alone
configures it and there is no base URL to set. NeuroLink calls
<base URL>/v1/decisions; a trailing /v1 on an override is accepted. The key
can equally come from the config passed to the SDK,
new NeuroLink({ credentials: { openaiDecisions: { apiKey } } }), or per call;
config set there counts when a decide() call picks the default decision
provider, exactly as the environment does. The classifier router's auto gate is
read differently: it looks at the constructor's credentials and the environment
only, so a credentials.openaiDecisions passed on one call does not influence
it. The provider id is openai-decisions: the openai provider is the chat
provider, which serves generate() and stream() and not decide(), and
credentials.openai does not configure decide. Its wire is OpenAI's own (named
questions in an array, an array of answers), so NeuroLink translates both ways;
the question types you write are unchanged. See the provider guide's
wire mapping.
OPENAI_API_KEY is shared with that chat provider. A host that set it only to
use OpenAI chat has therefore also configured decide, and built-in features will
use OpenAI Decisions whenever none of TypeSafe, Laya, XOR, SubQ, Perplexity or Cloudflare
Clef is configured. It comes last in the order, so it never displaces one that is.
The built-in consumers (model routing, tool routing, relevance compaction, the
summary gate, RAG planning) and the speech-to-text correction check then make paid
decision calls, though they still fail open. No switch turns it off while the key
is set (short of a gateway OPENAI_BASE_URL, below). neurolink.decide() and tryDecide() build a fresh provider for every
call, so a key OpenAI rejects costs one failing request per decision site each
time, and the 401 latch never carries across calls. The provider guide explains
when NeuroLink uses it
and
how the two providers share the key.
A gateway's key is never used for the default. OPENAI_API_KEY is also the
key of every OpenAI-compatible gateway (LiteLLM, vLLM and the like), so while
OPENAI_BASE_URL, OPENAI_API_BASE or credentials.openai.baseURL names a host other than
https://api.openai.com, NeuroLink does not take the environment key to be
OpenAI's and does not pick OpenAI Decisions as the default; the built-in
consumers send nothing to api.openai.com for it and behave as they did without a
decision model. The comparison is of scheme, host and port, without regard to case; a path,
a trailing slash and /v1 do not matter, a value that is not an http(s) URL
counts as another host, and a blank one as unset. Setting
OPENAI_DECISIONS_BASE_URL, or passing credentials.openaiDecisions with a key or
a base URL, lifts the check, because you routed this provider on purpose. A call
that names provider: "openai-decisions" is never checked: it uses the
environment key, and sends it to https://api.openai.com unless
OPENAI_DECISIONS_BASE_URL or credentials.openaiDecisions.baseURL says otherwise,
so a host whose key is a gateway's must route the provider on purpose or not use
it, and must not name it. The rule is data on the catalog entry
(decide.ambientKeyGuard) and applies to this provider alone; the provider guide
spells out
the cases.
It reads images but no video; see Images and video. Much about the endpoint is not established, including its question and option limits, image formats and rate limits; the guide lists what is not established.
The degradation contract. resolveDefaultDecisionProvider() returns
undefined when no decision provider is configured (a key, plus a base URL for
Laya and XOR, or an account id for Cloudflare Clef; SubQ needs only its key), and tryDecide() returns
null on any failure. There is no
configuration in which a missing, invalid, slow or unreachable decision model
changes NeuroLink's observable behaviour — it only ever falls back to what it did
before.
A credential the service does not accept disables that provider instance rather
than paying a round trip on every later call to be told so again. An XOR 403 or
402 is not that case: on a LiteLLM proxy it means the key's team lacks the model
or the budget, which an admin can fix, so the instance is not disabled. For
Perplexity only a 401 is that case: a 413 or a 400 over the model's context length
is max_tokens_exceeded, a 429 is rate_limit (retried once), and any other 4xx
is a non-retried invalid_request. For OpenAI Decisions only a 401 is that case
(a 403 is a non-retried invalid_request), and every 429 is a non-retried
rate_limit. For Cloudflare Clef only a 401 is that case
(a 403 was never seen, and would be a non-retried invalid_request, so a
permission fixed in the dashboard works at once), a 413 is max_tokens_exceeded,
a 429 is rate_limit (retried, except the Free plan's daily-allocation 429 with
code 4006, which a few seconds of backoff do not clear), and a 400 or 422 is a non-retried
invalid_request.
The same model is reachable two ways. Which one runs is decided once, in the constructor:
| Direct | Vercel AI Gateway | |
|---|---|---|
| Key | TYPESAFE_API_KEY |
AI_GATEWAY_API_KEY |
| Endpoint | api.typesafe.ai |
ai-gateway.vercel.sh/v4/ai/evaluation-model |
| Model named in | request body | ai-model-id header |
| Question vocabulary | noul |
boolean |
confidence |
on each answer | on providerMetadata, not on the answer |
| Billed by | TypeSafe | Vercel |
Holding both keys keeps the direct transport, so the confidence figures a
host already sees do not shift underneath it when a second key appears. Both
transports report the vendor's calibrated confidence — the gateway simply puts
it somewhere else, under providerMetadata.typesafe.confidence.<questionId>,
leaving the answer objects without one. Set TYPESAFE_TRANSPORT=gateway (or
credentials.typesafe.transport) to override.
max(probabilities) when an answer carries no confidence, and on a
near-certain answer the two agree. On an uncertain one they do not, and not by a
little: a measured four-way choice returned probabilities
{alpha 0.16, beta 0.28, gamma 0.33, delta 0.23} — a peak of 0.33 against a
reported confidence of 0.10. That gap straddles the default
minUpgradeConfidence of 0.3, so the derived number clears a bar the real one
fails and a near-random pick gets acted on as a confident one. The peak stays as
the fallback when neither source reports a confidence, and it is genuinely a
different quantity: an even distribution over N options lands near 1/N, not 0.
Usage is spelled differently too — input_tokens / output_tokens on the
direct API, inputTokens / outputTokens on the gateway. Both are read. A
decision is priced on input alone, so a parser that knows only one spelling does
not error: it reports zero tokens and costs every call at exactly $0.
export AI_GATEWAY_API_KEY=vck_... # gateway only — no TypeSafe key needed
export TYPESAFE_TRANSPORT=gateway # optional; forces the gateway when both keys existGateway keys are created at Vercel → your team → AI Gateway → API Keys.
403 with type customer_verification_required, and it arrives before the
model id is validated — so an otherwise-perfect request fails with a billing
error and no hint that the rest of it was fine. A key that has never billed
anything still authenticates: the three states are distinguishable, and worth
knowing apart when diagnosing a 4xx.
| Sent | Response |
|---|---|
No Authorization |
401 "Missing Authorization header" |
| Wrong key | 401 "Authentication failed. Check that your Vercel credential is valid and has access to AI Gateway." |
| Valid key, no card on file | 403 customer_verification_required |
Wrong ai-gateway-protocol-version |
400 "Unsupported gateway protocol version" |
All four were measured against the live service. The protocol-version header is
worth singling out: omitting it or sending anything other than 0.0.1 fails the
whole request rather than defaulting to a version.
import {
NeuroLink,
readDecisionChoice,
readDecisionBoolean,
} from "@juspay/neurolink";
const neurolink = new NeuroLink();
const result = await neurolink.tryDecide({
// null when unconfigured or failing
state: ticketText, // string OR structured JSON
questions: {
team: {
type: "choice",
instructions: "Which team should handle this?",
criteria: {
billing: "Payments, invoicing, refunds",
technical: "Bugs, outages, integrations",
sales: "Pricing, upgrades, new accounts",
},
},
urgent: { type: "boolean", instructions: "Does this express urgency?" },
frustration: {
type: "score",
instructions: "How frustrated is the sender?",
criteria: ["Calm", "Mildly annoyed", "Clearly frustrated", "Furious"],
},
},
});
const team = result && readDecisionChoice(result.answers, "team");
if (team && team.confidence >= 0.7) {
assignTo(team.choice);
} else {
assignToHumanTriage(); // low confidence is a signal, not an error
}readDecisionBoolean / readDecisionChoice / readDecisionScore validate at
runtime and return undefined for a missing id or a mismatched type, so no
call site needs a type assertion.
Use decide() instead of tryDecide() when you want the failure to surface;
it throws a ProviderError whose cause carries a typed kind
(authentication, rate_limit, max_tokens_exceeded, …).
More questions than a provider takes. A provider that caps the questions in
one request (Laya and Cloudflare Clef take 64) refuses a longer map from decide() with
max_tokens_exceeded. tryDecide(), which every built-in consumer calls,
splits the map at the cap instead, keeps the questions in the order given, runs
up to four batches at a time and joins the answers into one result: usage is
summed, the latency is the slowest batch's, and requestId lists the batches'
ids. A batch that fails costs only its own questions, which then have no answer
(the read* helpers return undefined for them, and every built-in consumer
carries on without that decision); tryDecide() returns null only when every
batch failed. The state and any media are sent with each batch, so a split
request costs the state's tokens once for each batch. A provider with no cap
(TypeSafe, XOR, OpenAI Decisions) always gets one request.
The same primitive is available as neurolink decide [state], which calls
decide() (not tryDecide()) and prints one line per answer:
npx @juspay/neurolink decide "Refund request for a damaged item" \
--questions '{"urgent":{"type":"boolean","instructions":"Is this urgent?"}}'To ask about an image or a video, add --image <path> (repeatable) or
--video <path>. XOR reads both, Perplexity, Cloudflare Clef and OpenAI Decisions
read images only, and TypeSafe, Laya and SubQ read neither:
npx @juspay/neurolink decide "A product photo from a listing." --provider xor \
--image ./front.png --image ./back.jpg \
--questions '{"color":{"type":"choice","instructions":"What color is the product?","criteria":{"red":"Mostly red","blue":"Mostly blue"}}}'See the CLI command reference for the full flag
list, including --state-file, --questions-file, --image, --video and
--format json.
readDecisionChoice returns ranked — every option sorted by probability,
highest first. One choice question over N options therefore ranks all N in a
single request. This is the basis for picking from a large catalogue.
A decision provider whose descriptor declares media limits reads images, and
possibly one video, alongside state. XOR does, and so do Perplexity, Cloudflare
Clef and OpenAI Decisions, which declare images only and no video; TypeSafe, Laya and SubQ
do not. Two optional fields on the request carry them, for decide() and
tryDecide() alike:
images— up to 8 images, the limit XOR, Perplexity and OpenAI Decisions each declare (OpenAI's is NeuroLink's own allowance, not a limit OpenAI documents); Cloudflare Clef takes up to 4.video— one video, for a provider that declares video (XOR).
Each image and the video takes the same three input forms:
- a
Buffer; - a local file path;
- a
data:image/…;base64,ordata:video/…;base64,URL.
const result = await neurolink.decide({
provider: "xor",
state: "A product photo from a listing.",
images: ["./front.png", "./back.jpg"],
questions: {
color: {
type: "choice",
instructions: "What color is the product?",
criteria: { red: "Mostly red", blue: "Mostly blue" },
},
},
});
console.log(result.mediaBytes); // encoded size of the images sentNeuroLink identifies a Buffer or a file from its bytes, not its extension — PNG,
JPEG, WebP and GIF images, and MP4, MOV and WebM video —
and sends it as a data: URL. A data: URL you pass yourself must hold base64
image or video content. Clef normalizes image/jpg to image/jpeg; other data URLs are sent as given. Perplexity and Cloudflare Clef read
PNG, JPEG and WebP only, and refuse a GIF before any request. OpenAI Decisions
sends every image as an inline data URL and checks no format list, because
OpenAI's list is not established.
What is refused. Everything NeuroLink can check is refused before any
request, as a non-retryable invalid_request:
- an
http(s)URL — media is not fetched for you, so pass a Buffer, a file path or adata:URL; - a string that is not a file path or a
data:URL, such as bare base64 or a URL with another scheme; - a missing file, a directory, or an empty Buffer or file;
- something that is not an image or a video;
- more than 8 images, or more than 4 for Cloudflare Clef;
- a request body over the provider's limit: 8 MB for XOR, 32 MiB for Perplexity and for OpenAI Decisions (NeuroLink's own number for the latter), 256,000 bytes for Cloudflare Clef. The limit applies to the encoded body, so the base64 form counts, and a photo usually has to be resized to well under 190 KB before Clef will take it. A file over it is refused from its size, before it is read;
- a video sent to a provider that declares no video (Perplexity, Cloudflare Clef, OpenAI Decisions);
- an image Perplexity cannot read: one of another type, or over 2,048 tiles of 32 × 32 pixels, which the API would otherwise hold for about a minute before answering 504;
- an image Cloudflare Clef cannot read: one that is not PNG, JPEG or WebP;
- any media sent to a provider that declares no media capability (TypeSafe, Laya). The message names the providers that accept media.
Images and a video can be sent together to XOR (up to 8 images and one video), but the model does not reliably tell the two apart.
The result carries mediaBytes, the encoded size of the media sent. The
decide spans carry decision.images.count and decision.media.bytes, and
never any base64. A deployment started without OPENJEV_IMAGES=1 answers 200
and silently ignores images, which NeuroLink cannot detect; see the
XOR provider guide.
This inverts the instinct you have from LLMs.
On TypeSafe, question count barely affects latency (measured against its live API):
| questions | round trip | input tokens |
|---|---|---|
| 1 | 393 ms | 310 |
| 10 | 390 ms | 481 |
| 100 | 423 ms | 2 281 |
| 400 | 465 ms | 8 581 |
These are TypeSafe's figures.
perplexity-deciderdiffers: about 0.3 to 1.1 s for one question, about 65 ms for each further question (a request takes at most 128), and an input time that grows faster than linearly with size: 3.9 s at 65,000 tokens, 10 s at 146,000 and 22.9 s at 251,000. See its latency section.cloudflare-cleftakes 0.3 to 1.0 s for a small request and 1.1 to 1.3 s for 64 questions (measured 2026-10-03). A 64-question request with the same questions and a shorter state took 1.5 s onclef-flashand 2.3 s onclefon 2026-10-04; see its latency section.openai-decisionshas not been timed: its latency is not measured.
On TypeSafe, 400 questions cost ~70 ms more than one. Concurrent requests, by contrast, queue: ten parallel calls take ~1.4 s wall with nine landing together at the end, while the server's own upstream time stays flat at 64–169 ms.
So on TypeSafe, 400 things in one request take ~465 ms; the same 400 as separate requests take roughly a minute. Add every question you might need to the call you are already making — speculative questions are nearly free on TypeSafe (on Perplexity each costs about 65 ms, up to 128 in a request; Cloudflare Clef takes up to 64), and a second round trip is not.
The classifier router gains a jev
strategy, and its default becomes auto — resolving to jev when a decision
provider is configured and heuristic when not.
One request asks for the difficulty tier, whether the task needs vision/tools/reasoning, whether carrying it out is risky, and which pool member to use — all at once, in ~400 ms on TypeSafe's Jev.
heuristic |
llm |
jev |
|
|---|---|---|---|
| Added latency | 0 ms | ~1–8 s | ~400 ms on TypeSafe |
| Cost per decision | none | a full LLM call | ~$0.00002 on TypeSafe |
| Confidence | keyword score | self-reported (defaults to 0.7) | as the provider reports it (calibrated on TypeSafe) |
| Picks a model directly | no | yes | yes |
The jev column is TypeSafe's figures; the
Perplexity guide gives its
latency and the confidence it reports. The
Cloudflare Clef guide gives its
latency; a prompt NeuroLink estimates over 16,000 tokens is refused there and
routing falls back to the heuristic, as it does on any failure.
Thresholds are asymmetric, because the two mistakes do not cost the same:
minUpgradeConfidence defaults to 0.3 (spending more on a wrong guess costs
money) and minDowngradeConfidence to 0.6 (spending less on a wrong guess
produces a wrong answer).
The key upgrades routing; it does not switch routing on. The classifier router is still opt-in (
classifierRouter.enabled) and still needs apool, because NeuroLink cannot invent the set of models you are willing to route between. What the key changes is which classifier runs inside a router you already enabled.
Enabling classifierRouter.catalog widens the routable pool beyond what you
declared by hand: candidates are built from the 64-model registry (7
providers — see the model catalogue
for which), intersected with the credentials this host actually holds, and
ranked by a deterministic formula whenever no decision provider is available
to choose among them.
Without catalog |
With catalog.enabled |
|
|---|---|---|
| Candidate pool | only the declared pool |
declared pool plus registry matches |
| Fallback pick | first pool member | tierScore()-ranked, tier-aware |
| Cap | none needed | maxModels, default 120 |
See the model catalogue for how candidates are filtered, rendered, and ranked.
A per-request compactionThreshold option lowers the point at which history
gets compacted, below the 0.8-of-window default. The jev strategy can fill
it in automatically from a four-level scope rubric (current-message through
everything) — and the mapping is a one-directional invariant: it can only
ever lower the 0.8 default, never raise it, because over-filling a window is
an unrecoverable provider error.
See per-request context budget for the rubric, the invariant, and how the threshold scales the compaction target.
Before the existing positional compaction stages run, an optional Stage 0
asks, per eligible message, whether the current request still needs it — at
~400 ms for the whole batch regardless of message count (on TypeSafe;
perplexity-decider differs, taking about 0.3 to 1.1 s for one question and about
65 ms for each further one, plus an input time that grows faster than linearly
with size; cloudflare-clef took 0.3 to 1.0 s for a small request on 2026-10-03, and its
endpoint refuses nothing below 16,000 estimated state tokens, but a state that large takes seconds).
Only plain user/assistant text is eligible, the most recent messages are never
touched, and a message is dropped only on a confident "no."
| Positional stages (1–4) | Stage 0 (relevance) | |
|---|---|---|
| Basis for keeping | position (recency) | relevance to the current request |
| Drop granularity | whole messages / summarized ranges | whole messages |
| Runs when | always, once over budget | decision provider configured, request known, and over budget |
See relevance-driven compaction for the eligibility rules, the drop cap, and the separate summary-quality gate on Stage 3.
The shipped tool router asks a generative model for {servers: string[]} on
a 15-second budget — a shape that cannot express uncertainty. A decision
model instead asks one yes/no question per server, answered with a probability,
and a server is excluded only on a confident "no" (minDropConfidence default
0.6), because dropping a needed server breaks the turn while keeping an unneeded
one only costs a few tokens.
A measured wording change moved unrelated servers from a mean probability of 0.31 (dropping 12 of 39 unneeded servers) to a mean of 0.03 (dropping 37 of 39, with zero wrong drops) — seen in tool / MCP routing by decision model, which also covers the exact question shape and its size guards.
RAGPipelineConfig.decide lets each RAG query get its own topK/hybrid/
graph/rerank plan instead of one fixed configuration for every query. An
explicit per-call QueryOptions field always wins over the plan, and a
capability the pipeline wasn't configured with can never be switched on by
it.
See per-query RAG retrieval planning for the breadth rubric and why its confidence bar is deliberately lower than tool routing's or compaction's.
Every one of those calls is stamped with a site — routing, toolRouting,
contextRelevance, summaryGate or ragPlan (the DecisionSite union) — and
goes through one private path on NeuroLink, which is also where a bound
tryDecide() lands. For the four sites that run inside a NeuroLink request —
routing, toolRouting, contextRelevance and summaryGate — that path
forwards the outer generate() / stream() call's per-call credentials,
abortSignal and requestId (the top-level option, else
context.requestId) to the decision, so a request that carries
credentials: { laya: {...} } routes and compacts against that account
rather than the instance's; for routing the per-call credentials also count
towards the auto strategy's activation, so a host that holds no decision key
itself still routes a request that carries one. ragPlan runs inside a
host-wired RAGPipeline, outside any NeuroLink request, so it receives only
what the host puts on the options it hands tryDecide (see "Host-wired
consumers" below). The path also adds decision.site and
decision.host_question_count to the model.decision span; emits
decision:before / decision:after on the instance emitter; and runs the
decisionHooks described next. Without hooks or listeners, the decision
payload — state and questions — is unchanged; per-call credentials are still
forwarded, so the full request on the wire (account, base URL) can differ from
the instance's.
Latency on a decision model is flat in question count, so a host with its own
questions about the same request pays nothing to ask them in the round trip
NeuroLink is already making. decisionHooks on the constructor does exactly
that — one request per site, never a second:
const neurolink = new NeuroLink({
classifierRouter: { enabled: true, pool },
decisionHooks: {
// Called before each site's call with NeuroLink's own questions. Return
// extra questions under ids of your choosing, or undefined to add none.
extendQuestions: ({ site, state }) =>
site === "routing"
? {
tone: { type: "boolean", instructions: "Is the request polite?" },
team: {
type: "choice",
instructions: "Which team owns this?",
criteria: { payments: "Money movement", platform: "Rest" },
},
}
: undefined,
// Called with YOUR answers under YOUR ids, plus the full result.
onAnswers: ({ site, answers, result }) => {
const team = readDecisionChoice(answers, "team");
if (team && team.confidence > 0.7) {
tagRequest(team.choice);
}
},
},
});What the funnel guarantees:
| Concern | Behaviour |
|---|---|
| Id collisions | Host questions travel as host__0, host__1, … on the wire, so they can never collide with difficulty, model, server__N, msg__N, …; onAnswers maps them back to your ids. |
| Malformed questions | Each is checked at runtime (isDecisionQuestion); an invalid one is dropped with a warning, its siblings still go. |
| The provider's cap | Additions are capped at maxQuestions − NeuroLink's own count, dropped from the end with a warning. Without this one question too many would refuse the WHOLE request, and NeuroLink's routing would degrade. This is a count cap, and only Laya declares one (64); OpenAI Decisions declares none. TypeSafe and XOR cap a request by tokens, not by count: their decisionLimits() reading has no maxQuestions key (absent, never Infinity), and every well-formed addition goes on the wire. For TypeSafe (enforcedLocally: false) size your questions with decisionLimits() and estimateDecisionStateTokens() — an over-long host question can still make the request exceed its state-plus-question or combined-request budget, and that refusal is not caught locally. For XOR the reading also carries media (8 images, one video, an 8 MiB body), which is the ceiling the questions share with the state and any media. |
| A throwing or slow hook | Logged at warn; the decision proceeds with NeuroLink's own questions. Each hook is bounded by hookTimeoutMs (default 2000 ms) because both sit on the request path; the bound must be a finite number from 1 to 2^31 − 1 ms — anything else, Infinity included, falls back to the default with one warning, because a timer given such a delay fires immediately. Neither hook can change what NeuroLink does with its own answers — onAnswers and the events receive copies. |
| An unreadable return | A return value from extendQuestions that cannot be read or copied (a proxy, a throwing getter, a function-valued field) is treated as a hook failure: logged at warn, and the decision proceeds with NeuroLink's own questions. Every question that is accepted travels as a copy, so editing your object after the hook returned changes nothing on the wire. |
| A throwing listener | A decision:before / decision:after listener that throws is logged at warn and the call continues; the events are observe-only, and their state / questions / answers are copies — editing them changes neither the request on the wire nor NeuroLink's own question objects. |
| NeuroLink's consumers | See only their own ids, exactly as without hooks. If anything host-facing throws after the call returned, the consumer still gets its own answers from that call; if it throws before, the call is made with NeuroLink's own questions. A host cannot turn a decision into an exception. |
| Host-wired consumers | A RAGPipeline or ClassifierRouter you constructed with decide: (o) => neurolink.tryDecide(o) gets the same treatment — the consumer stamps the site, tryDecide honours it. Overriding or patching decide() on the instance is honoured too: every site call, tryDecide included, dispatches through it. |
| Question ids | Your ids are ordinary map keys — __proto__ or constructor come back under exactly that name in onAnswers and hostAnswers. |
| No hooks, no provider | Nothing changes. Without a decision provider — none in the environment, the instance credentials or the request's per-call credentials — no hook runs and no event fires; tryDecide returns null exactly as it did before hooks existed. |
The two events are observe-only and fire once per site call whether or not it produced a result, so a decision path that has stopped working stays visible:
import type { DecisionAfterEvent } from "@juspay/neurolink";
neurolink.getEventEmitter().on("decision:after", (event) => {
const { site, answers, hostAnswers, latencyMs, provider, model, result } =
event as DecisionAfterEvent; // result is null when the call failed
});decision:before carries { site, state, questions, hostQuestionCount } —
everything about to be sent, host questions already namespaced — plus the
sessionId and requestId of the surrounding request, so a host can join the
events of one turn. Both events fire only when a decision provider is
configured; without one there is no call to observe.
neurolink.decisionLimits({ provider?, model?, credentials? }) returns the
figures the pre-flight check compares against, resolved for one model —
provider as decide() resolves it, model as model ?? the model decide() sends
(the one registered for the provider: LAYA_MODEL / TYPESAFE_MODEL as read
when providers were registered, else the default — a later change to the
variable moves neither), the per-model entry flattened over the base:
const limits = neurolink.decisionLimits(); // null with no decision provider
// laya, typed-decisions → { provider: "laya", model: "typed-decisions",
// maxStateTokens: 768, maxQuestions: 64, nonAsciiTokensPerChar: 1.5,
// enforcedLocally: true }
// typesafe → { maxStateTokens: 33000, enforcedLocally: false }
// (no maxQuestions key: TypeSafe caps by tokens, not by count)
// xor → { maxStateTokens: 200000, nonAsciiTokensPerChar: 1,
// media: { maxImages: 8, video: true, maxRequestBytes: 8388608 },
// enforcedLocally: true }
// (no maxQuestions key either; `media` is absent for the text-only providers)
// openai-decisions → { maxStateTokens: 922000,
// media: { maxImages: 8, video: false, maxRequestBytes: 33554432 },
// enforcedLocally: false }
// (922,000 is the model page's input figure, reported and never enforced;
// the three media numbers are NeuroLink's own allowance, not OpenAI's)
if (
limits &&
estimateDecisionStateTokens(state, limits) > limits.maxStateTokens
) {
state = shorten(state);
}maxQuestions is present only for a provider that caps by question count
(Laya); a reading without the key has no count cap, and a host should test
for the key rather than compare against Infinity. media is present only
for a provider that reads images or video (XOR, Perplexity, Cloudflare Clef,
OpenAI Decisions); its absence is what the media refusal for TypeSafe and Laya is
based on.
enforcedLocally says who refuses: NeuroLink itself, before any network call
(Laya and XOR, whose servers would silently truncate or fail inside the
engine), or the provider's server
(TypeSafe and OpenAI Decisions, whose descriptor rows are marked advisory and are
reported but never enforced here — a local refusal would turn a server ceiling
into a client one that drifts from it). estimateDecisionStateTokens is the very
function the pre-flight refusal uses, so a state it sizes as fitting is sent
and one it sizes as over is refused; pass the reading so non-ASCII text is
charged at the model's measured rate. A named provider is read whether or
not it is configured — its limits are facts about the model — while the
default requires a configured provider, as tryDecide() does.
Laya reads far less than Jev. About 768 tokens of state on
typed-decisions and multilingual, and 320 on english, auto or an
unrecognised model name, against Jev's ~33,000. Laya's server does not refuse a
longer state; it answers from the first 1,024 tokens (512 on english). So the
provider estimates the state's size — counting non-Latin characters at a rate
measured per checkpoint — and refuses anything over the limit locally with
max_tokens_exceeded, before any network call. The estimate errs toward
refusing.
XOR shares one prefill between the state, the questions and any media.
NeuroLink allows about 200,000 estimated tokens of state (about four characters
per token for ASCII, one token per character for other scripts) and refuses more
locally with max_tokens_exceeded. That figure is a conservative default under
the deployment's 250,000-token prefill, which the questions and any images or
video also draw on; it has not been measured against a live deployment. A
request that passes the local check can therefore still be refused by the
server as too long. That arrives as max_tokens_exceeded (not retried) when
the status is 413 or the message says the context was too long, and otherwise as
server, retried once. XOR has no question cap in NeuroLink, and its server takes 2 to 255
options on a choice or score.
Perplexity's input ceiling covers more than the state. Measured on a real
account in October 2026: the server reads under 262,144 input tokens per request,
counted over the state, the questions and the images, and refuses input of
262,144 tokens or more with an explicit 400 instead of cutting the state off.
Images are billed as input tokens, about one for each 32 × 32 tile plus 95 to
103 more in the single-image requests measured, which looks like fixed request
overhead and not a charge per image. They count toward the ceiling at one token
for each tile: eight 2,048-tile images added 16,384 to a request that was then
refused. It takes at most 128 questions and 8 images. The documented
32 MiB body limit was not measured. NeuroLink's own window is 100,000 estimated
tokens of state, a deliberate local limit and not the server's; a state estimated
above it, or more than 128 questions in a decide() call, is refused locally
with max_tokens_exceeded (tryDecide() splits the questions at 128). The estimate is about 3.8 characters per token for ASCII
text and half a token per non-ASCII character (measured: CJK 0.46 tokens per
character, Devanagari 0.44), and it counts the state's text only. Measured on
40,000-character samples, it over-counts English prose (4.34 characters per
token) and is close for TypeScript (3.84). It runs about 13% low on minified JSON
(3.36), which stays far under the ceiling at the window: 380,000 characters of
JSON are about 113,000 real tokens. It runs much lower on log lines (1.35) and
arrays of integers (1.16), which are about 281,000 and 328,000 real tokens for
the same 380,000 characters, and on emoji (2.6 tokens per code point), so a state
that is mostly one of those can pass the window and still be refused by the
server. A request that passes it can also be refused when the questions or the
images push the input over. That refusal arrives as max_tokens_exceeded too,
whichever of the server's two wordings it uses (one for text alone, one when
images are in the request), and is not retried. A state estimated above the window
needs a smaller state or another provider: no setting raises it, so a larger
state that would fit the server can be sent only through a decision provider with
a larger window. Perplexity documents a request rate of 10 per second for the
account's tier, and on the account tested 14 parallel requests got 10 answers and
4 replies of 429.
The Workers AI endpoint's state window changed (hosted service or model:
unknown). On 2026-10-03 and 2026-10-04 it ignored Clef state text past about
2,048 tokens, far less than the documented 64K (a 133-call follow-up on 2026-10-04
found the same cut on both models). On 2026-10-07 clef-flash read a fact at the
very end of states of 190,153 tokens (a digit array) and 86,819 (prose), and
clef read 52,154 tokens of prose and answered HTTP 529, code 5012, for the
190,153-token array.
The estimator charges digits 1 token each, punctuation 0.75, astral characters
3 and other non-ASCII characters 1.5, with other text about 4 characters a token.
It refuses more than 16,000 estimated state tokens with max_tokens_exceeded,
before any network call: at most about 18,400 real tokens (it counts prose 35%
over and a digit array 14% under), 2.8 times fewer than clef was seen to read.
The rates now approximate real tokens and no longer stand for a cut; other object
shapes and Unicode sequences may differ.
A request takes at most 64 questions (tryDecide() splits larger maps) and
four images. Exactly four succeeded and a fifth was refused on both models.
NeuroLink retains a 256,000-byte cap for the encoded body, including base64
images. Both models accepted 520,000 text characters and refused 525,000 with
413/code 5021 on 2026-10-04; clef-flash had accepted 262,000 and refused
270,000 on 2026-10-03.
On 2026-10-05 both models accepted PNG request bodies up to 492,485 bytes and refused
532,505 (clef-flash) and 532,499 (clef) with the same 413; the reason the limits
moved is unknown.
See the Clef guide's limits.
Two separate size ceilings (TypeSafe), both enforced:
state+ the single longest question ≤ ~33 000 tokens (measured exactly: 33 002 accepted, 33 003 rejected). Usually the binding one.state+ all questions combined ≤ ~64 000 tokens.
Questions do not compete with state for the 33 K budget — a near-ceiling state plus 400 extra questions is accepted.
Three different error envelopes. TypeSafe returns detail as an object for
application errors and as an array for schema validation; the gateway uses
neither and returns {"error":{"message","type"}}. The provider normalises all
three into one DecisionError. The validation shape echoes your input back,
so it is never logged or surfaced.
On the gateway, error.type decides the kind, not the HTTP status — a 403
carrying invalid_request_error is a bad request, not a bad credential, and
must not disable the provider instance. Reading the status alone would trip the
auth circuit breaker on a working key.
403 vs 401 are inverted on the direct API, from the usual convention and
from TypeSafe's own docs: a missing Authorization header returns 403, an
invalid key returns 401. The gateway does not share this quirk — it
returns 401 for both, and reserves 403 for account state.
On TypeSafe, max_tokens_exceeded arrives with no message field — the one
error a long-context caller is most likely to hit. The provider supplies the
sentence. Perplexity's over-length 400 does carry a message, and its provider
reads the kind from that text.
Latency: TypeSafe's p50 is ~400 ms warm, but the first call after idle
measured 2.0–2.7 s. The default timeout is 5 s for that reason, and every
internal call site is fail-open regardless. Perplexity's time scales with the
input and with the question count. Input time grows faster than linearly: 3.9 s
at 65,000 tokens, 10 s at 146,000 and 22.9 s at 251,000 (about 17,000, 15,000
and 11,000 tokens per second), against 0.3 to 1.1 s for one question on a short
state, plus about 65 ms for each question after the first (128 minimal questions
took 8.9 s). Its default timeout is 10 s plus 100 ms for each question, so 10.1 s
for one question and 22.8 s for 128, and a timeoutMs you pass is used as given.
A timeout is retried once, so a request that stalls can take about twice the
timeout. Pass a larger timeoutMs for any state of more than about 100,000 real
tokens, which log lines and arrays of numbers reach well inside the window. The
provider guide
lists the timeout setting for each built-in consumer. On 2026-10-03 Cloudflare Clef
answered a small request in 0.3 to 1.0 s, and 64 questions in 1.1 s on clef-flash
and 1.3 s on clef (1.5 s and 2.3 s on 2026-10-04); its default timeout is 5 s, and timeoutMs changes it for one call.
Privacy: when enabled, the state you send leaves the machine. For model
routing that is the prompt text; for compaction and tool routing it is earlier
conversation text. With no decision provider configured, nothing is transmitted.
A PERPLEXITY_API_KEY set for the Perplexity text provider counts as a decision
provider's key, so with it set and none of TypeSafe, Laya, XOR or SubQ configured, the
state of a decision goes to Perplexity. The provider guide lists
what each consumer sends.
Likewise CLOUDFLARE_API_KEY with CLOUDFLARE_ACCOUNT_ID, set for the Workers AI
text provider, counts as a decision provider's credentials: with them set and none
of TypeSafe, Laya, XOR, SubQ or Perplexity configured, the state of a decision goes to
Cloudflare. And an OPENAI_API_KEY set for the OpenAI chat provider counts as a
decision provider's key as well: with it set and none of the six earlier
providers configured, the state of a decision goes to OpenAI, unless
OPENAI_BASE_URL, OPENAI_API_BASE or credentials.openai.baseURL names another host than
https://api.openai.com, in which case that key is a gateway's and is not used
by default.
Cost: TypeSafe, $0.042 per million input tokens, output free. Perplexity,
$0.04 per million input tokens (image tokens included), output free. Cloudflare
Clef, $0.24 per million input tokens for clef and $0.09 for clef-flash;
no output price is listed. OpenAI Decisions, $0.10 per million input tokens, input
only, from OpenAI's Decisions guide
as read on 2026-10-07; regional and long-context multipliers are not modelled.
"Cannot hallucinate" is a claim about output shape, not answer correctness: a decision model cannot return malformed JSON or an option you did not offer, but it can still be wrong. TypeSafe reports ~68% accuracy on its own 711-case benchmark, against ~73% for a frontier model. It wins cost and latency on every row and loses accuracy on every row — so it is right for decisions that are gated and reversible, and wrong for final answers.
- It reads literally. It answers the question you wrote, not the one you meant. Split an ambiguous question into two and combine them in code.
- Ask about the act, not the subject. "This task touches money" scores high
on ordinary code that merely concerns money. The risk question in
classifyJevis worded to exclude writing and testing such code, precisely because the naive phrasing escalated everything. - It is not a calculator. Counting, arithmetic and date comparison are
unreliable — dates are read as text, not ordered quantities. A
scoreis for thresholding and ranking, not for reading an exact magnitude off. - Irrelevant state costs accuracy. Filter before sending.
- It never explains itself. No rationale field exists, which rules it out where a decision must be auditable.
- Option order can matter. Test with reordered
criteriaif a call is close. - Don't invert criteria. A
booleanwhosetruedescription means "no" performs measurably worse.
Tune thresholds on your own labelled data if the decision matters, and once you
have, pin TYPESAFE_MODEL to a version id such as jev-1.13.0 — jev-latest
is an alias and can move under you, invalidating a tuned threshold silently.
The decide inference type is provider-neutral by construction. Another
decision model needs:
- An
AIProviderNamemember and a<Name>Modelsenum (src/lib/constants/enums.ts, outside the generated regions). - A provider class extending
SystemOneDecisionProvider(src/lib/providers/systemOneDecision.ts), which owns the request loop, retries, the auth circuit breaker and answer parsing, and supplies the throwinggetAISDKModel()/executeStream()stubs, the same shape the embedding-only providers (voyage.ts,jina.ts) use. The class supplies its endpoint, headers, body, error parsing and messages. - A descriptor with
inferenceKinds: ["decide"], no auto-select ranks, andhealthCheck: "env-only". That one field is what keeps a text-less model out of every generation fallback chain; nothing else needs to know the provider by name. A provider that reads images or video also declaresdecisionLimits.media; without it, a request that carries media is refused before any network call. - A registration block, a credentials slice, a manifest, and the usual Tier-3
onboarding artifacts —
pnpm run verify:provider-onboardingenumerates them.
The Tier-2 catalog JSON path cannot be used: its schema pins tier: 2, accepts
only an 8-flag text-generation capability vocabulary, and requires
defaultMaxOutputTokens and pricingPerMTok.output — none of which a model
that emits no text can honestly supply.
pnpm run test:decide # live + degradation
pnpm run test:providers-mocked # the mocked wire contractThe suite drives dist/index.js only. Live tests skip without
TYPESAFE_API_KEY; the degradation and discriminator tests run unconditionally,
because "behaves correctly with no key" and "a text-less provider is unreachable
from generation" are the contracts that matter most.