@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
JavaScript
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,
};
}