@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
316 lines (315 loc) • 12.5 kB
TypeScript
/**
* Decision inference types — typed, calibrated judgements from a model that
* emits no text.
*
* `decide` is the third inference type, alongside `generate` and `stream`. 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. TypeSafe's Jev is the first such model; Vercel's AI SDK models the
* same class separately from `LanguageModelV4`, for the same reason.
*
* The vocabulary here is deliberately **provider-neutral**. TypeSafe calls a
* yes/no question a `noul` and answers it in a field of the same name; both
* the Vercel AI SDK and Pydantic AI independently renamed that to `boolean` /
* `probability` when exposing it. This file follows them, and the mapping to
* a vendor's wire format belongs in that vendor's provider.
*
* Names are domain-prefixed `Decision*` to stay globally unique across
* `src/lib/types/` (CLAUDE.md rule 9). Note `evaluate`/`Evaluation*` is a
* different, unrelated feature — scoring a generated response with RAGAS.
*/
import type { NeurolinkCredentials } from "./providers.js";
/**
* Anything a decision model accepts as free-form content. `state` may be a
* plain string or structured JSON (chat logs, records); `instructions` and
* `criteria` values likewise.
*/
export type DecisionInput = string | number | boolean | readonly DecisionInput[] | {
readonly [key: string]: DecisionInput;
};
/** The content being judged. One shared state per request. */
export type DecisionState = DecisionInput;
/**
* A yes/no question. Answered with a probability and **no confidence of its
* own** — gate it on distance from 0.5 via {@link decisionBooleanConfidence}.
*/
export type DecisionBooleanQuestion = {
type: "boolean";
instructions: DecisionInput;
/** Optional descriptions of what a yes and a no mean. */
criteria?: {
true?: DecisionInput | null;
false?: DecisionInput | null;
};
};
/**
* Pick one option. `criteria` maps option name → rubric description.
*
* The answer carries the full probability distribution, not just the winner,
* so a single choice question over N options also **ranks** all N.
*/
export type DecisionChoiceQuestion = {
type: "choice";
instructions: DecisionInput;
criteria: Readonly<Record<string, DecisionInput | null>>;
};
/**
* Rate against an ordered rubric. The answer is the **0-based index**, so a
* four-level rubric scores 0–3. At least two levels are required.
*/
export type DecisionScoreQuestion = {
type: "score";
instructions: DecisionInput;
criteria: readonly (DecisionInput | null)[];
};
export type DecisionQuestion = DecisionBooleanQuestion | DecisionChoiceQuestion | DecisionScoreQuestion;
/** Questions keyed by an id you choose; answers return under the same ids. */
export type DecisionQuestionMap = Readonly<Record<string, DecisionQuestion>>;
export type DecisionBooleanAnswer = {
type: "boolean";
/** Probability the statement is true, 0–1. */
probability: number;
};
export type DecisionChoiceAnswer = {
type: "choice";
/** The highest-probability option name. */
choice: string;
/** Every option mapped to its probability. Key order is not stable. */
probabilities: Readonly<Record<string, number>>;
/** Calibrated certainty, 0–1, derived from the distribution. */
confidence: number;
};
export type DecisionScoreAnswer = {
type: "score";
/** Probability-weighted level index; lands between levels. */
score: number;
/** Level index (as a string key) → the description supplied. */
legend: Readonly<Record<string, string>>;
/** Level index (as a string key) → probability. */
probabilities: Readonly<Record<string, number>>;
confidence: number;
};
export type DecisionAnswer = DecisionBooleanAnswer | DecisionChoiceAnswer | DecisionScoreAnswer;
export type DecisionAnswerMap = Readonly<Record<string, DecisionAnswer>>;
export type DecisionUsage = {
inputTokens: number;
outputTokens: number;
};
/** An image or video as the caller supplies it: raw bytes, a file path, or a data URL. */
export type DecisionMediaSource = Buffer | string;
export type DecisionRequest = {
state: DecisionState;
questions: DecisionQuestionMap;
/**
* Images the model reads alongside `state`. Each is a Buffer, a local file
* path or a `data:image/…;base64,` URL; an http(s) URL is refused. Only a
* provider whose descriptor declares `decisionLimits.media` accepts them.
*/
images?: readonly DecisionMediaSource[];
/** One video, in the same forms. Sending images as well is allowed. */
video?: DecisionMediaSource;
/** Overrides the provider's configured model for this call only. */
model?: string;
signal?: AbortSignal;
/** Overrides the configured timeout for this call only. */
timeoutMs?: number;
};
export type DecisionResult = {
/** The RESOLVED model id (e.g. "jev-1.13.0"), not the alias sent. */
model: string;
provider: string;
answers: DecisionAnswerMap;
usage: DecisionUsage;
/** Vendor request id, for support escalation. */
requestId?: string;
/** Wall-clock round trip measured client-side. */
latencyMs: number;
/** Server-side time behind the edge; isolates model time from network. */
upstreamMs?: number;
/** Encoded size of the images and video sent. Absent for a text-only request. */
mediaBytes?: number;
};
/**
* Input a decision provider can actually read, declared on its descriptor.
*
* A request over either limit is refused before any network call with
* `max_tokens_exceeded`, so no decision is ever made on input the model
* silently cut off. Every internal consumer already treats that error as
* "carry on as before".
*/
export type DecisionLimits = {
/**
* Estimated tokens of `state` (serialized first when it is not a string)
* for any model NOT listed in `models` — an alias, a typo, a self-hosted
* name — so it should be the tightest window the provider has.
*/
maxStateTokens: number;
/** Absent when the provider has no cap on questions per request. */
maxQuestions?: number;
/**
* Tokens charged per non-ASCII character. The default estimate assumes ~4
* characters per token, which holds for English and is several times too
* generous for other scripts on an English tokenizer. Absent = default
* estimate for every character.
*/
nonAsciiTokensPerChar?: number;
/**
* Tokens charged per ASCII digit. A tokenizer that reads every digit as its
* own token makes numbers, ids and timestamps several times longer than the
* default estimate of four characters per token. Absent = digits are
* estimated like any other ASCII character.
*/
digitTokensPerChar?: number;
/**
* Tokens charged per ASCII punctuation or symbol character (`,` `.` `{` `"`
* `:` and the like). The same tokenizers that read each digit alone read most
* punctuation alone too, so JSON, logs and lists of numbers run far above four
* characters a token. Absent = punctuation is estimated like any other ASCII
* character.
*/
symbolTokensPerChar?: number;
/**
* Tokens charged per character outside the Basic Multilingual Plane (emoji
* and the like), which is counted separately from `nonAsciiTokensPerChar`
* because it costs about twice as much. Absent = charged at
* `nonAsciiTokensPerChar`.
*/
astralTokensPerChar?: number;
/** Per-model limits, keyed by model id; each field overrides the one above. */
models?: Readonly<Record<string, {
maxStateTokens: number;
nonAsciiTokensPerChar?: number;
}>>;
/** What the provider accepts besides text. Absent means text only. */
media?: DecisionMediaLimits;
};
export type DecisionMediaLimits = {
/** Most images one request may carry. */
maxImages: number;
/** Whether a video is accepted (at most one). */
video: boolean;
/** Ceiling on the encoded request body, in bytes. */
maxRequestBytes: number;
};
/** An image's size in pixels, read from its header. */
export type DecisionImageDimensions = {
width: number;
height: number;
};
/** Media after preparation: everything is a `data:` URL, ready for the wire. */
export type DecisionPreparedMedia = {
images: string[];
video?: string;
/** Encoded size of every URL above. */
bytes: number;
};
/**
* Discriminated by a string literal, not a boolean: the package is also
* compiled without strictNullChecks, where a boolean discriminant does not
* narrow.
*/
export type DecisionMediaResult = {
status: "prepared";
media: DecisionPreparedMedia | undefined;
} | {
status: "refused";
message: string;
};
export type DecisionMediaKind = "image" | "video";
/** One image or video turned into a data URL, or the reason it could not be. Same literal-discriminant rule as above. */
export type DecisionMediaConversion = {
status: "ok";
dataUrl: string;
} | {
status: "error";
message: string;
};
/** A media file read from disk, or the reason it could not be. */
export type DecisionMediaFileRead = {
status: "ok";
buffer: Buffer;
} | {
status: "error";
message: string;
};
/**
* Why a decision call failed, normalised across vendors. TypeSafe alone
* returns two different error envelopes, so a provider must flatten them.
*/
export type DecisionErrorKind = "authentication" | "invalid_request" | "max_tokens_exceeded" | "rate_limit" | "overloaded" | "server" | "timeout" | "network";
export type DecisionError = {
kind: DecisionErrorKind;
message: string;
status?: number;
requestId?: string;
/** True when a retry could plausibly succeed. */
retryable: boolean;
};
/** One entry from a decision provider's model listing. */
export type DecisionModelCard = {
name: string;
description: string;
releaseDate: string;
};
/**
* Narrowed view of a choice answer, safe to consume without re-narrowing.
* Returned by the reader helpers so call sites need no type assertion.
*/
export type DecisionChoiceReading = {
choice: string;
confidence: number;
probabilities: Readonly<Record<string, number>>;
/**
* Every option ordered by probability, highest first. A choice question is
* therefore also a ranking — the basis for catalogue selection.
*/
ranked: readonly {
name: string;
probability: number;
}[];
};
export type DecisionScoreReading = {
score: number;
confidence: number;
legend: Readonly<Record<string, string>>;
probabilities: Readonly<Record<string, number>>;
};
/**
* What `NeuroLink.decide()` accepts: a decision request plus the usual
* provider/credential selection every inference type shares.
*/
export type DecisionOptions = DecisionRequest & {
/**
* Provider name or alias. Defaults to the first registered provider whose
* descriptor declares `"decide"` in `inferenceKinds`.
*/
provider?: string;
/** Per-call credential overrides, as `generate()` takes. */
credentials?: NeurolinkCredentials;
};
/**
* Injected fail-open decision caller — typically a bound `NeuroLink.tryDecide`,
* which returns `null` on any failure rather than throwing.
*
* Every internal consumer of the `decide` inference type takes one of these
* instead of importing a provider, exactly as the tool router takes a
* `generateFn`. It is what keeps the consumers provider-import-free, and what
* makes "no decision provider configured" indistinguishable from "the call
* failed" at every call site: both are `null`, and both mean *carry on as
* before*.
*/
export type DecisionCallerFn = (options: DecisionOptions) => Promise<DecisionResult | null>;
/**
* Shared gate for acting on a yes/no answer.
*
* A `boolean` answer carries no confidence of its own, so two separate bars
* apply: which side of the coin flip it fell on, and how far from the flip it
* landed. Both must be cleared, which is why "probability 0.55" never counts
* as a yes.
*/
export type DecisionBooleanGate = {
/** Minimum probability to read the answer as "yes". Default 0.5. */
minProbability?: number;
/** Minimum {@link decisionBooleanConfidence} to act at all. Default 0.4. */
minConfidence?: number;
};