@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
193 lines (192 loc) • 8.5 kB
JavaScript
/**
* Lifecycle callback firing + dedupe.
*
* The same thrown error can travel through multiple layers that each
* want to surface it to the consumer's `onError`:
* - LifecycleMiddleware's `wrapGenerate` / `wrapStream` catch
* - `BaseProvider.wrapStreamWithLifecycleCallbacks` (raw-fetch
* streaming providers that bypass AI SDK middleware)
* - `BaseProvider.fireLifecycleErrorCallback` (top-level provider catch)
* - `NeuroLink.generate()` / `NeuroLink.stream()` (early-resolution
* failures, before the language model is wrapped)
*
* Without a shared dedupe these layers would fire `onError` multiple
* times for one logical failure. This module stamps a non-enumerable
* `Symbol.for("neurolink.onErrorFired")` on the error the first time
* a firing site is reached; subsequent sites observe the stamp and
* skip their own fire.
*
* `Symbol.for` (rather than a local Symbol) so the same key works
* across modules — anyone who can read the symbol can read the stamp.
*
* Frozen / sealed / non-extensible errors: `Object.defineProperty`
* throws, we catch and proceed. Worst case is a single duplicate fire
* (the pre-shared-marker behaviour). `WeakSet`-based bookkeeping
* would handle this case cleanly but the symbol stamp is preferred
* for cross-realm consistency: a Symbol.for-keyed property survives
* structuredClone / cross-realm postMessage where a closed-over
* WeakSet does not.
*/
const ON_ERROR_FIRED = Symbol.for("neurolink.onErrorFired");
/**
* Read the consumer-facing lifecycle callbacks buried inside a request's
* middleware blob. The parameter is `unknown` on purpose: request options
* arrive as several structurally-unrelated shapes (StreamOptions,
* TextGenerationOptions), and the lifecycle branch is an optional add-on
* none of them declare — a single structural view keeps the read cast-free
* at every call site.
*
* Shared (moved out of `BaseProvider`) because two independent firing sites
* now need the same read: `BaseProvider.wrapStreamWithLifecycleCallbacks`
* (every provider's stream) and `GoogleVertexProvider.wrapStreamResultWithLifecycle`
* (Vertex's own native-stream firer, which must check the SAME
* `isLifecycleStreamCallbacksOwnedByBaseProvider` marker on the SAME nested
* config object that BaseProvider's wrapper reads from `options`, so that
* both sites agree on who owns firing without a second marker).
*/
export function getLifecycleMiddlewareConfig(options) {
return options?.middleware
?.middlewareConfig?.lifecycle?.config;
}
/**
* Marks a stream lifecycle config as "BaseProvider.wrapStreamWithLifecycleCallbacks
* already fires onChunk/onFinish for this config, authoritatively, from
* NeuroLink's own normalized stream — the model-level lifecycle middleware
* (middleware/builtin/lifecycle.ts) must not ALSO fire them from the raw
* per-provider V3 chunks it sees."
*
* Only `NeuroLink.applyStreamLifecycleMiddleware()` stamps a config with this
* marker, and only for stream()'s config: it is the one function that builds
* a lifecycle config fresh from stream()'s top-level onChunk/onFinish/onError
* options, for a call that unconditionally passes through
* `BaseProvider.wrapStreamWithLifecycleCallbacks` afterwards — see that
* method's doc comment ("the only point every provider's stream passes
* through unconditionally"). Without this marker both layers fire
* onChunk/onFinish for the same logical stream: once (unreliable, raw,
* provider-shape-dependent) from the model-level middleware, once (reliable,
* normalized) from BaseProvider.
*
* `NeuroLink.applyGenerateLifecycleMiddleware()` must NEVER stamp its config
* this way. BaseProvider has no equivalent guaranteed-delivery layer for
* generate()'s onFinish: `BaseProvider.finalizeNativeGenerate`'s own doc
* comment states onFinish is deliberately NOT fired there because "the
* native paths now wrap their model, so the lifecycle middleware fires it" —
* the model-level middleware is the SOLE firing site for every native
* generate loop that goes through the model wrap (sagemaker,
* openaiChatCompletionsBase, anthropic). The one generate path that bypasses
* the model wrap (GoogleVertex's native @google/genai loop) fires onFinish
* itself via `BaseProvider.fireGenerateOnFinish` precisely because the
* middleware never runs for it. Stamping the generate config would make the
* model-level middleware suppress its own firing with nothing left to fire
* it, silently breaking onFinish for every one of those providers.
*
* A config a consumer builds directly — e.g. calling the exported
* `createLifecycleMiddleware` and applying it to their own model via the
* `ai` package's `wrapLanguageModel`, entirely outside NeuroLink's
* BaseProvider/stream()/generate() call path — is never stamped, so that
* standalone use keeps firing onChunk/onFinish exactly as before (CLAUDE.md
* rule 5: public SDK API must not break existing callers).
*
* Symbol-stamped on the object itself (not a typed field) for the same
* reason as `ON_ERROR_FIRED` above: it survives across separate module
* copies of the same config object where a closed-over WeakSet would not.
*/
const STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER = Symbol.for("neurolink.lifecycleStreamCallbacksOwnedByBaseProvider");
/**
* Stamp a stream lifecycle config object with
* `STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER`. See that symbol's doc comment
* for who may call this (only `applyStreamLifecycleMiddleware`) and why.
*/
export function markLifecycleStreamCallbacksOwnedByBaseProvider(config) {
try {
Object.defineProperty(config, STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER, {
value: true,
enumerable: false,
writable: false,
configurable: false,
});
}
catch {
// Non-extensible config object — worst case is the pre-fix double fire.
}
}
/**
* True when `markLifecycleStreamCallbacksOwnedByBaseProvider` already
* stamped this config. The model-level lifecycle middleware reads this to
* decide whether to fire its own onChunk/onFinish for a stream config.
*/
export function isLifecycleStreamCallbacksOwnedByBaseProvider(config) {
if (config === null || typeof config !== "object") {
return false;
}
return (config[STREAM_CALLBACKS_OWNED_BY_BASE_PROVIDER] === true);
}
function stampFired(error) {
try {
Object.defineProperty(error, ON_ERROR_FIRED, {
value: true,
enumerable: false,
writable: false,
configurable: false,
});
}
catch {
// Non-extensible — fall through; worst case is a duplicate fire.
}
}
/**
* Returns true when `markLifecycleErrorFired` or a previous
* `fireOnErrorOnce` call has already stamped this error.
*/
export function hasLifecycleErrorFired(error) {
if (error === null || typeof error !== "object") {
return false;
}
return error[ON_ERROR_FIRED] === true;
}
/**
* Stamps the error as already-fired without invoking any callback.
* Use this from sites that already invoked `onError` via their own
* path (e.g. a provider-specific raw-fetch stream wrapper) so the
* shared dedupe still works.
*/
export function markLifecycleErrorFired(error) {
if (error === null || typeof error !== "object") {
return;
}
if (error[ON_ERROR_FIRED] === true) {
return;
}
stampFired(error);
}
/**
* Fire the consumer's `onError` once per logical failure.
*
* - No-op when `onError` is missing.
* - No-op when the error is already stamped (any prior layer fired).
* - Otherwise: stamps the error, then invokes the callback.
*
* The callback is fire-and-forget; rejections are swallowed so a
* faulty handler can't mask the original throw. Callers that need
* to AWAIT the callback (e.g. to enforce a timeout) should use
* `hasLifecycleErrorFired` + `markLifecycleErrorFired` directly and
* run the callback themselves.
*/
export function fireOnErrorOnce(onError, error, payload) {
if (typeof onError !== "function") {
return;
}
if (hasLifecycleErrorFired(error)) {
return;
}
if (error !== null && typeof error === "object") {
stampFired(error);
}
try {
const result = onError(payload);
Promise.resolve(result).catch(() => undefined);
}
catch {
// Consumer callback errors must not poison the original throw.
}
}