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

198 lines (197 loc) • 9.16 kB
import { createBlockedResponse, createBlockedStream, applyContentFiltering, handlePrecallGuardrails, } from "../utils/guardrailsUtils.js"; import { logger } from "../../utils/logger.js"; import { generateOnceNative } from "../../utils/nativeSingleShot.js"; /** * Filter each contiguous run of text parts as one string. * * A prohibited term that straddles two adjacent text parts is invisible to a * per-part filter, and the parts are concatenated downstream (`lifecycle.ts` * joins adjacent text), so the term reached the caller whole. Anthropic's * native path emits one text part per content block, so adjacent parts are a * real shape, not a theoretical one. Non-text parts keep their position; a run * is rebuilt as a single text part only when the filter changed it. */ const filterTextRuns = (content, badWords, context) => { const out = []; let run = []; const flushRun = () => { if (run.length === 0) { return; } const merged = run.map((part) => part.text).join(""); const filtered = applyContentFiltering(merged, badWords, context); if (filtered.hasChanges) { out.push({ ...run[0], text: filtered.filteredText }); } else { out.push(...run); } run = []; }; for (const part of content) { if (part.type === "text") { run.push(part); } else { flushRun(); out.push(part); } } flushRun(); return out; }; /** * Create Guardrails AI middleware for content filtering and policy enforcement * @param config Configuration for the guardrails middleware * @returns NeuroLink middleware instance */ /** * Turn whatever a caller put in `filterModel` into a model handle. * * A handle is used as-is. A string is resolved through NeuroLink's own * provider factory, accepting either "provider:model" or a bare model id. * Imported lazily so the middleware module does not pull the provider factory * into every bundle that merely registers guardrails. */ async function resolveFilterModel(filterModel) { if (typeof filterModel !== "string") { return filterModel; } const [maybeProvider, ...rest] = filterModel.split(":"); const hasProvider = rest.length > 0; const { ProviderFactory } = await import("../../factories/providerFactory.js"); const provider = await ProviderFactory.createProvider(hasProvider ? maybeProvider : undefined, hasProvider ? rest.join(":") : filterModel); // `getModel()` is BaseProvider's sanctioned public handle but is not on the // narrower `AIProvider` type, so narrow at the boundary rather than assert // through it (Critical Rule 14). const handle = provider; if (typeof handle.getModel !== "function") { throw new Error(`guardrails: provider for "${filterModel}" exposes no model handle`); } return handle.getModel(); } export function createGuardrailsMiddleware(config = {}) { const metadata = { id: "guardrails", name: "Guardrails AI", description: "Provides comprehensive content filtering and policy enforcement using custom rules, AI models, and precall evaluation to filter inappropriate content before it reaches the LLM.", priority: 90, defaultEnabled: true, }; // WeakMap to store blocking state from transformParams to wrap methods const blockingState = new WeakMap(); const middleware = { specificationVersion: "v3", transformParams: async ({ params }) => { if (config.precallEvaluation?.enabled) { const { shouldBlock, transformedParams } = await handlePrecallGuardrails(params, config.precallEvaluation); // Store the blocking state for use in wrap methods blockingState.set(transformedParams, shouldBlock); return transformedParams; } return params; }, wrapGenerate: async ({ doGenerate, params }) => { logger.debug(`[GuardrailsMiddleware] Applying to generate call.`); // Check if this request should be blocked (set by transformParams) if (config.precallEvaluation?.enabled && blockingState.get(params)) { return createBlockedResponse(); } let result = await doGenerate(); result = { ...result, content: filterTextRuns(result.content, config.badWords, "generate"), }; if (config.modelFilter?.enabled && config.modelFilter.filterModel) { logger.debug(`[GuardrailsMiddleware] Invoking model-based filter.`); try { const text = result.content .filter((part) => part.type === "text") .map((part) => part.text) .join(""); const filterPrompt = `Is the following text safe? Respond with only "safe" or "unsafe".\n\nText: "${text}"`; // `ModelFilterConfig.filterModel` is typed `LanguageModel`, which // admits a bare model id, and the documented examples used one. // `generateOnceNative` needs a handle exposing doGenerate, so a // string threw, the catch below logged it, and the turn returned // unfiltered — a security control that silently did nothing. const filterModel = await resolveFilterModel(config.modelFilter.filterModel); const { text: filterResponse } = await generateOnceNative(filterModel, { prompt: filterPrompt }); if (filterResponse.toLowerCase().trim() === "unsafe") { logger.warn(`[GuardrailsMiddleware] Model-based filter flagged content as unsafe.`); result = { ...result, content: result.content.map((part) => part.type === "text" ? { ...part, text: "<REDACTED BY AI GUARDRAIL>" } : part), }; } } catch (error) { logger.error(`[GuardrailsMiddleware] Model-based filter failed.`, { error, }); } } return result; }, wrapStream: async ({ doStream, params }) => { logger.debug(`[GuardrailsMiddleware] Applying to stream call.`); // Check if this request should be blocked (set by transformParams) if (config.precallEvaluation?.enabled && blockingState.get(params)) { return { stream: createBlockedStream(), rawCall: { rawPrompt: null, rawSettings: {} }, warnings: [], }; } const { stream, ...rest } = await doStream(); let hasYieldedChunks = false; // With bad-word filtering on, a text run is buffered and filtered as one // string: a term split across deltas is invisible per delta and every // consumer reassembles it. The run is released when a non-text part // arrives or the stream ends, so the guardrail trades incremental // delivery of that run for not being bypassable by chunking. With // filtering off, deltas pass through untouched and unbuffered. const bufferTextRuns = config.badWords?.enabled === true; let pendingText; const releaseText = (controller) => { if (!pendingText) { return; } const filtered = applyContentFiltering(pendingText.delta, config.badWords, "stream"); controller.enqueue(filtered.hasChanges ? { ...pendingText, delta: filtered.filteredText } : pendingText); pendingText = undefined; }; const transformStream = new TransformStream({ transform(chunk, controller) { hasYieldedChunks = true; if (chunk.type === "text-delta" && bufferTextRuns) { pendingText = pendingText ? { ...pendingText, delta: pendingText.delta + chunk.delta } : chunk; return; } releaseText(controller); controller.enqueue(chunk); }, flush(controller) { releaseText(controller); if (!hasYieldedChunks) { logger.warn(`[GuardrailsMiddleware] Stream ended without yielding any chunks`); } }, }); return { stream: stream.pipeThrough(transformStream), ...rest, }; }, }; return { ...middleware, metadata, }; }