UNPKG

@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

609 lines (608 loc) • 26.6 kB
/** * 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 { NeurolinkTraceContext } from "./observability.js"; 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; /** * True when these figures describe the provider's own server-side ceiling, * published so callers can plan against it, and NeuroLink does NOT refuse * locally — the server does, with its own `max_tokens_exceeded`. Absent or * false means NeuroLink refuses an over-limit request before any network * call. `NeuroLink.decisionLimits()` reports the distinction as * `enforcedLocally`. */ advisory?: boolean; }; /** * A provider's decision limits resolved for one model — the figure the * pre-flight check compares against, flattened from a descriptor's * `decisionLimits` with the per-model override applied. * * Returned by `NeuroLink.decisionLimits()` so a host extending NeuroLink's * questions (see {@link DecisionHooks}) can size its additions before the * call, instead of discovering the cap when the whole request is refused. */ export type DecisionLimitsReading = { provider: string; model: string; maxStateTokens: number; /** * Absent when the provider caps a request by tokens (or bytes) rather * than by question count — TypeSafe and XOR; `Infinity` is never used. */ maxQuestions?: number; nonAsciiTokensPerChar?: number; /** Tokens charged per ASCII digit; see {@link DecisionLimits.digitTokensPerChar}. */ digitTokensPerChar?: number; /** Tokens charged per ASCII punctuation/symbol; see {@link DecisionLimits.symbolTokensPerChar}. */ symbolTokensPerChar?: number; /** Tokens charged per astral character; see {@link DecisionLimits.astralTokensPerChar}. */ astralTokensPerChar?: number; /** What the provider accepts besides text; absent means text only. */ media?: DecisionMediaLimits; /** * Whether NeuroLink itself refuses an over-limit request before any network * call (Laya), or merely reports the server's ceiling (TypeSafe). */ enforcedLocally: boolean; }; /** What `NeuroLink.decisionLimits()` accepts: the same selection `decide()` takes. */ export type DecisionLimitsQuery = { /** Provider name or alias; defaults to the configured decision provider. */ provider?: string; /** Defaults to the provider's configured model, then its default model. */ model?: string; credentials?: NeurolinkCredentials; }; 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; /** Per-call trace attributes (user, session, tags, trace name, metadata) for this decision's spans. */ context?: NeurolinkTraceContext; }; /** * The built-in consumers of the `decide` inference type — one name per place * NeuroLink asks a decision model something on its own behalf. Each consumer * stamps its request with its site, which is what lets a host's * {@link DecisionHooks} tell the calls apart and lets telemetry attribute them. * * - `routing` — the classifier router (difficulty, capabilities, risk, model, context scope) * - `toolRouting` — one yes/no per MCP server * - `contextRelevance` — one yes/no per earlier message, compaction stage 0 * - `summaryGate` — accept or reject a generated summary, compaction stage 3 * - `ragPlan` — per-query retrieval plan (`RAGPipeline` only) */ export type DecisionSite = "routing" | "toolRouting" | "contextRelevance" | "summaryGate" | "ragPlan"; /** * Options an internal decision site hands its caller: the public * {@link DecisionOptions} plus in-process context that never reaches the * wire. A bound `NeuroLink.tryDecide` accepts it unchanged; a host wrapping * one can read `site` to see which consumer is asking. */ export type DecisionCallerOptions = DecisionOptions & { /** Which built-in consumer is asking. Unset on a host's own `tryDecide` call. */ site?: DecisionSite; /** The conversation the outer request belongs to. Never sent to the model. */ sessionId?: string; /** The outer request's id. Never sent to the model. */ requestId?: string; }; /** * What an internal decision site inherits from the `generate()` / `stream()` * call it runs inside: the per-call credentials (so the decision goes to the * caller's provider account, not the instance's), the abort signal, and the * ids a host's {@link DecisionHooks} may want to correlate on. */ export type DecisionSiteContext = { credentials?: NeurolinkCredentials; signal?: AbortSignal; sessionId?: string; requestId?: string; }; /** What a site call adds to its decision span beyond the request itself. */ export type DecisionSiteAttributes = { site: DecisionSite; /** Questions a host's `extendQuestions` hook added; 0 without hooks. */ hostQuestionCount: number; }; /** * What a {@link DecisionHooks} callback sees before the call: the site, the * state NeuroLink is about to send, and NeuroLink's own questions — as * copies, so a hook that edits them cannot reach the wire request. */ export type DecisionHookContext = { site: DecisionSite; state: DecisionState; /** NeuroLink's own questions for this site, keyed by its own ids. */ questions: DecisionQuestionMap; sessionId?: string; requestId?: string; }; /** What `onAnswers` receives once the decision has returned. */ export type DecisionHookAnswersContext = DecisionHookContext & { /** * The host's answers, keyed by the ids the host used in `extendQuestions` * — the namespacing applied on the wire is undone here. Empty when the * host added no questions. */ answers: DecisionAnswerMap; /** The whole result, including NeuroLink's own answers under its own ids. */ result: DecisionResult; }; /** * Ride along on the decision calls NeuroLink already makes. * * Latency on a decision model is flat in question count, so a host with its * own yes/no or choice questions about the same request pays nothing to ask * them in the round trip NeuroLink is making anyway — one request per * {@link DecisionSite}, never a second. The host's questions are namespaced * on the wire so they can never collide with NeuroLink's, capped so they can * never push the request past the provider's question limit (which would * refuse the whole call and degrade NeuroLink's own routing), and answered * back under the host's original ids. * * Both hooks are fail-open: a hook that throws is logged and the decision * proceeds with NeuroLink's own questions, and neither can change what * NeuroLink does with its own answers — every value a hook or listener is * handed is a copy. Without hooks, the decision payload (state and * questions) is exactly what the consumer built; the outer request's * per-call `credentials` are still forwarded, so the account and base URL * on the wire can differ from the instance's. Hooks are inert without a * decision provider: neither runs, and no event fires. */ export type DecisionHooks = { /** * Return extra questions to send with this site's call, or `undefined` to * add none. An invalid question is dropped with a warning; questions past * the provider's cap are dropped from the end, also with a warning. */ extendQuestions?: (context: DecisionHookContext) => DecisionQuestionMap | undefined | Promise<DecisionQuestionMap | undefined>; /** * Called with the host's answers (under the host's ids) and the full result * whenever a site call returns one. Observe-only: it receives a copy, so * mutating it cannot reach the answers NeuroLink's own consumer reads. */ onAnswers?: (context: DecisionHookAnswersContext) => void | Promise<void>; /** * Upper bound, in milliseconds, on each hook call. Both hooks sit on the * request path (routing, tool routing, compaction, RAG planning), so a * hook that hangs would stall the turn; past this bound the call proceeds * as if the hook had returned nothing, with a warning. Default 2000. Must * be a finite number from 1 to {@link MAX_DECISION_HOOK_TIMEOUT_MS}; * anything else (including `Infinity`) falls back to the default with one * warning, since a timer given such a delay fires immediately. */ hookTimeoutMs?: number; }; /** Default bound on a `DecisionHooks` callback, in milliseconds. */ export declare const DEFAULT_DECISION_HOOK_TIMEOUT_MS = 2000; /** * Largest `hookTimeoutMs` a timer honours (2^31 − 1 ms, about 24.8 days). * A delay past it — or `Infinity`, `NaN`, 0, a negative — fires at once, * so such a value falls back to the default instead of timing every hook * out immediately. */ export declare const MAX_DECISION_HOOK_TIMEOUT_MS = 2147483647; /** * Payload of the `decision:before` event, emitted once per site call. `state` * and `questions` are copies: a listener that edits them changes neither * the request on the wire nor the consumer's own question objects. */ export type DecisionBeforeEvent = { site: DecisionSite; state: DecisionState; /** Everything about to be sent, NeuroLink's own questions plus the host's (namespaced). */ questions: DecisionQuestionMap; hostQuestionCount: number; sessionId?: string; requestId?: string; }; /** * Payload of the `decision:after` event, emitted once per site call whether * or not it produced a result — a decision path that stopped working must * stay observable. `result` is null when an emitted call failed; with no * decision provider configured, no event fires at all. */ export type DecisionAfterEvent = { site: DecisionSite; /** NeuroLink's own answers, under its own ids. Empty when `result` is null. */ answers: DecisionAnswerMap; /** The host's answers, under the host's original ids. Empty when `result` is null. */ hostAnswers: DecisionAnswerMap; latencyMs: number; provider?: string; model?: string; result: DecisionResult | null; sessionId?: string; requestId?: string; }; /** * 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*. * * The options carry a {@link DecisionSite} stamp; a bound `tryDecide` routes * a stamped request through the host's {@link DecisionHooks}. */ export type DecisionCallerFn = (options: DecisionCallerOptions) => 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; }; /** The wire family a decide provider speaks. */ export type DecideDialectName = "system-one" | "openai-decisions"; export type DecisionAnswerReading = { readonly answers: Readonly<Record<string, DecisionAnswer>>; /** Ids of answers the server returned that could not be parsed. */ readonly dropped: readonly string[]; /** Ids of questions the model declined to answer; they have no answer. */ readonly refused?: readonly string[]; }; /** * The request body keys prepared media is written under. Set together with * `decisionLimits.media`, which says how much of it a request may carry. */ export type DecideMedia = { /** Written only when the request carries at least one image. */ imagesKey: string; /** Written only when the request carries a video; required exactly when `decisionLimits.media.video` is true. */ videoKey?: string; }; /** * Says when a decide provider's key may be used as the default decision * provider. A key found only in the environment may belong to something else: * `OPENAI_API_KEY` is also the key of every OpenAI-compatible gateway, and * sending a gateway's key and the decision state to the vendor's own host would * disclose both. With a guard, the environment key is skipped for default * resolution while any variable in `baseURLEnvVars`, or any credentials field in * `credentialBaseURLs`, holds a URL whose origin (scheme, host and port, * compared case-insensitively; path and trailing slash ignored) is not in * `trustedOrigins`; a value that does not parse as an http(s) URL counts as * untrusted, and a blank one as unset. * * It does not apply once the host routed the provider on purpose: the * provider's own base-URL variable is set, or `credentials.<key>` supplies a key * or a base URL. It never applies to a call that names the provider. */ export type DecideAmbientKeyGuard = { /** Environment variables that move the key's other users off the vendor's host. */ baseURLEnvVars: readonly string[]; /** Origins that are the vendor's own, such as `https://api.openai.com`. */ trustedOrigins: readonly string[]; /** * Credentials fields that move the key's other users off the vendor's host, * read from the same `credentials` the resolver is given (the instance's * merged with the call's), such as the chat provider's `credentials.openai` * `baseURL`. Omitted means only the environment is read. */ credentialBaseURLs?: readonly DecideAmbientKeyCredentialField[]; }; /** One credentials field a {@link DecideAmbientKeyGuard} reads: `credentials[key][field]`. */ export type DecideAmbientKeyCredentialField = { /** The `NeurolinkCredentials` key of the slice, such as `openai`. */ key: string; /** The field in that slice. Only `baseURL` is read today. */ field: "baseURL"; }; /** What a dialect lays out as one request body. */ export type DecisionDialectBody = { readonly state: DecisionState; /** Each question already written by the dialect's `encodeQuestion`, by id. */ readonly questions: Readonly<Record<string, Record<string, unknown>>>; /** Undefined means the request names no model. */ readonly model?: string; readonly media?: DecisionPreparedMedia; /** Where a layout with named media fields puts them; other layouts ignore it. */ readonly mediaKeys?: Readonly<DecideMedia>; }; /** * How questions are written to, and answers read from, a decide wire. One * dialect serves every vendor that speaks the same layout. */ export type DecisionDialect = { readonly name: DecideDialectName; encodeQuestion: (question: DecisionQuestion) => Record<string, unknown>; buildBody: (input: DecisionDialectBody) => Record<string, unknown>; /** * `undefined` means the response has no usable answers container. `asked` * is the request's own question map, for a wire that echoes names rather * than the ids sent. */ readAnswers: (decoded: Readonly<Record<string, unknown>>, reportedConfidence: Readonly<Record<string, number>>, asked: DecisionQuestionMap) => DecisionAnswerReading | undefined; };