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

184 lines (183 loc) • 9.27 kB
import { logger } from "../../utils/logger.js"; import { isRecoverableError } from "../../utils/errorHandling.js"; import { fireOnErrorOnce, isLifecycleStreamCallbacksOwnedByBaseProvider, } from "../../utils/lifecycleCallbacks.js"; /** * Normalize a thrown value to an `Error` while preserving structured * fields (`code`, `status`, `statusCode`, `retryAfter`, `details`, * `cause`, etc.) that downstream retry logic and SDK callers depend on. * * Previously the lifecycle middleware did * `error instanceof Error ? error : new Error(String(error))`, which * silently dropped every custom property on non-Error throws — so a * provider's `{ code: "RATE_LIMITED", retryAfter: 30 }` rejection * surfaced to consumers as a bare `Error("[object Object]")`. */ function normalizeToError(error) { if (error instanceof Error) { return error; } if (typeof error === "object" && error !== null) { const obj = error; const msg = typeof obj.message === "string" ? obj.message : String(error); // Object.assign keeps Error's prototype chain (including the // captured stack from this synthetic Error's construction) while // copying enumerable fields from the original throw. return Object.assign(new Error(msg), obj); } return new Error(String(error)); } export function createLifecycleMiddleware(config = {}) { const metadata = { id: "lifecycle", name: "Lifecycle Callbacks", description: "Provides onFinish, onError, and onChunk callbacks for generation and streaming lifecycle events", priority: 110, defaultEnabled: false, }; const middleware = { specificationVersion: "v3", wrapGenerate: async ({ doGenerate, }) => { const startTime = Date.now(); try { const result = await doGenerate(); if (config.onFinish) { try { const content = result.content ?.map((c) => c.type === "text" ? c.text : "") .join("") ?? ""; const callbackResult = config.onFinish({ text: content, usage: result.usage ? { promptTokens: result.usage.inputTokens?.total ?? 0, completionTokens: result.usage.outputTokens?.total ?? 0, } : undefined, duration: Date.now() - startTime, finishReason: String(result.finishReason ?? ""), }); Promise.resolve(callbackResult).catch((e) => { logger.warn("[LifecycleMiddleware] onFinish callback error:", e); }); } catch (e) { logger.warn("[LifecycleMiddleware] onFinish callback error:", e); } } return result; } catch (error) { const err = normalizeToError(error); // fireOnErrorOnce stamps a Symbol on `err` so the SDK-level catch // in neurolink.ts (and baseProvider.handleProviderError) skips // its own onError fire for the same logical failure. fireOnErrorOnce(config.onError, err, { error: err, duration: Date.now() - startTime, recoverable: isRecoverableError(err), }); // Rethrow the normalized err (not the raw `error`) so the // fired-mark and any preserved structured fields propagate. throw err; } }, wrapStream: async ({ doStream, }) => { const startTime = Date.now(); try { const result = await doStream(); if (!config.onChunk && !config.onFinish && !config.onError) { return result; } // BaseProvider.wrapStreamWithLifecycleCallbacks already fires // onChunk/onFinish for this exact config object, from NeuroLink's // own normalized stream, for every NeuroLink-internal stream() call // regardless of provider/transport. When that marker is present, // firing onChunk/onFinish here too would deliver each one twice — // once from these raw, provider-shaped V3 chunks, once from // BaseProvider's normalized ones. onError is NOT covered by this // flag: it keeps its own, independent dedup via `fireOnErrorOnce`'s // shared fired-marker below, unchanged. const streamCallbacksOwnedByBaseProvider = isLifecycleStreamCallbacksOwnedByBaseProvider(config); let sequenceNumber = 0; let accumulatedText = ""; const transformStream = new TransformStream({ transform(chunk, controller) { try { // V3 stream parts carry the delta on `delta` (see // LanguageModelV3StreamPart in types/aiCompat.ts); `textDelta` // is a V2-era AI-SDK field that V3 providers (e.g. Bedrock's // bridge, this package's own openaiChatCompletionsBase) never // set. Reading `chunk.textDelta` alone silently produced // `undefined`, which string-concatenated into a literal // "undefined" per chunk. Falling back to `textDelta` keeps any // caller still emitting the older shape working unchanged. const textDelta = chunk.type === "text-delta" ? (chunk.delta ?? chunk.textDelta) : undefined; if (chunk.type === "text-delta" && textDelta !== undefined) { accumulatedText += textDelta; } if (config.onChunk && chunk.type && !streamCallbacksOwnedByBaseProvider) { try { const callbackResult = config.onChunk({ type: chunk.type, textDelta, sequenceNumber: sequenceNumber++, }); Promise.resolve(callbackResult).catch((e) => { logger.warn("[LifecycleMiddleware] onChunk callback error:", e); }); } catch (e) { logger.warn("[LifecycleMiddleware] onChunk callback error:", e); } } controller.enqueue(chunk); } catch (error) { const err = normalizeToError(error); fireOnErrorOnce(config.onError, err, { error: err, duration: Date.now() - startTime, recoverable: isRecoverableError(err), }); throw err; } }, flush() { if (config.onFinish && !streamCallbacksOwnedByBaseProvider) { try { const callbackResult = config.onFinish({ text: accumulatedText, duration: Date.now() - startTime, }); Promise.resolve(callbackResult).catch((e) => { logger.warn("[LifecycleMiddleware] onFinish callback error:", e); }); } catch (e) { logger.warn("[LifecycleMiddleware] onFinish callback error:", e); } } }, }); return { ...result, stream: result.stream.pipeThrough(transformStream), }; } catch (error) { const err = normalizeToError(error); fireOnErrorOnce(config.onError, err, { error: err, duration: Date.now() - startTime, recoverable: isRecoverableError(err), }); throw err; } }, }; return { ...middleware, metadata }; }