ZeroLeaksDocs
Shield SDKProvider Wrappers

Provider Wrappers

Wrappers for OpenAI, Anthropic, Groq, Google Gen AI, Mistral, LangChain.js, MCP clients, and the Vercel AI SDK that harden system prompts, check user messages and tool results for injection, and redact prompt leaks, credentials, exfiltration links, and canaries from responses, and guardrails for the OpenAI Agents SDK.

Wrappers use local detection unless you configure a hosted detector. To use the Shield API, pass createHostedDetector(...).options() as detect and scanToolResults; see hosted integration.

Each wrapper runs harden(), detect(), sanitize(), and scanOutputText() around your client's calls. By default, onDetection is "block", so an injection in a user message or tool result throws InjectionDetectedError before the request is sent. Credentials and exfiltration links in the response are also redacted by default.

Shared behavior

These points apply to the OpenAI, Anthropic, and Groq wrappers.

  • Only create is guarded. The wrapper returns a proxy of your client, typed as your client, with create replaced: chat.completions.create and responses.create for OpenAI, messages.create for Anthropic, and chat.completions.create for Groq. Every other method and resource works as before, such as OpenAI's chat.completions.retrieve() and responses.stream(), Anthropic's messages.stream() and messages.countTokens(), or client.beta, but their requests and responses are not checked or guarded. A method that returns a new client, such as withOptions(), returns an unwrapped client.
  • create() returns a plain Promise rather than the SDK's APIPromise, so withResponse() and asResponse() type-check and are undefined at runtime. With stream: true in "buffer" or "chunked" mode, it resolves to a plain async iterable rather than the SDK's Stream, so controller, toReadableStream(), and tee() are missing too; read it with for await. A route handler that returns new Response(stream.toReadableStream()) compiles and then throws a TypeError.
  • User messages and tool results are scanned. Every user message goes through detect(), including earlier turns in the history, and so does every tool result. See Tool results. Assistant and system messages are not scanned.
  • The system prompt is derived if you omit it. systemPrompt is what output is checked against for prompt leaks. Without it, the wrapper uses the request's instructions as sent, before hardening: the text of every system and developer message for Chat Completions, instructions and every system and developer message for the Responses API, and system for Anthropic. If there are none, the prompt leak check is skipped, but the output guard still runs.
  • Your request objects are not modified. The wrapper copies the messages before hardening them. Guarded text is written back into the response object it returns.
  • secondaryDetector and escalate run. These wrappers call detectAsync() when the detect or scanToolResults options have a secondaryDetector or an escalate detector. A secondaryDetector can overrule a detection before the request is blocked; escalate gets text that nothing flagged but the classifier scored at or above its minScore (0.15 by default), for a slower check such as an LLM call. See detect() options. The request waits for them unless you set parallelDetection. The AI SDK middleware also awaits asynchronous detectors before generation; its lower-level synchronous wrapParams helper requires wrapParamsAsync for these options.
  • Repeated text is checked once. Agent loops send the full history on every call, so each wrapper caches detection results for the last 256 texts it checked, for texts up to 64K characters, and reuses them. Detection gives the same result for the same text and options, so reusing these results changes performance without changing detection. At its largest, the cache holds about 32MB of text. Results are not cached when a secondaryDetector or escalate is set, since they could answer differently. With onDetection: "warn", a flagged message that stays in the history is reported to onInjectionDetected on every call.

Options

Every integration takes these options, with three exceptions: ShieldCallbackHandler takes all but harden, streamingSanitize, streamingChunkSize, and parallelDetection; shieldMcpClient takes only detect, scanToolResults, onDetection, and onInjectionDetected, plus options of its own; and each OpenAI Agents SDK guardrail takes the part of them it uses, plus options of its own.

OptionTypeDefaultDescription
systemPromptstringderived from the requestThe system prompt to check output against for leaks
hardenHardenOptions | false{}Hardening options, or false to disable
detectLocalDetectOptions | false{}Detection options for user messages, or false to disable
scanToolResultsboolean | LocalDetectOptionstrueDetection on tool results and documents. true uses the detect options, a LocalDetectOptions object replaces them, and false disables it. detect: false does not disable it.
parallelDetectionbooleanfalseRun escalate at the same time as the model call instead of before it, and hold the response until its verdict is in. See Parallel detection.
sanitizeSanitizeOptions | false{}Prompt leak sanitization options, or false to disable
outputScanOutputOptions | false{}Output scanning options, or false to disable. By default credentials and exfiltration links are redacted, and personal data is not scanned.
onOutputFindings(findings: OutputFinding[]) => voidnoneCalled with the findings each time output scanning finds something
blockOnOutputFindingsbooleanfalseWhen true, throw OutputBlockedError instead of redacting if a finding is high or critical severity
canarystring | booleannonePlant a canary token in the hardened system prompt and treat its appearance in output as a leak. true creates one when the wrapper is created. See Canary.
streamingSanitize"buffer" | "chunked" | "passthrough""buffer"How streamed output is guarded. See Streaming.
streamingChunkSizenumber8192Characters per chunk in "chunked" mode
throwOnLeakbooleanfalseWhen true, throw LeakDetectedError instead of redacting a prompt leak or a canary
onDetection"block" | "warn""block""block" throws InjectionDetectedError; "warn" calls onInjectionDetected and sends the request anyway
onInjectionDetected(result: LocalDetectResult, source: "user" | "tool") => voidnoneCalled when an injection is detected, in both modes. source says whether it was in a user message or a tool result.
onLeakDetected(result: SanitizeResult) => voidnoneCalled when a prompt leak or a canary is found in the output

Tool results

Tool output is the main way an injection reaches an agent: a web page, email, or file the agent reads carries instructions for the model. Each wrapper runs detect() on every tool result in the request, including earlier turns, and on a match with onDetection: "block" throws InjectionDetectedError with source: "tool" before the request is sent.

IntegrationWhat is scanned
OpenAI and Groq Chat CompletionsMessages with role: "tool" or role: "function", string content or text parts
OpenAI ResponsesInput items of type function_call_output, custom_tool_call_output, mcp_call (its output), local_shell_call_output, shell_call_output (stdout and stderr), and apply_patch_call_output. computer_call_output items are screenshots and are skipped.
Anthropictool_result blocks (string content, or the text, document, and search result blocks inside), document blocks with a plain text or content source, search_result blocks, and the fetched page in web_fetch_tool_result blocks, in any message
AI SDKtool-result parts in any message: on AI SDK 5 and later an output of type text, error-text, json, error-json, or the text parts of content; on AI SDK 4 the result
Google Gen AIfunctionResponse parts (the string values of response) and inlineData parts with a text/*, application/json, or application/xml MIME type (the first 64KB), in any turn, and the parts callable tools return during automatic function calling
MistralMessages with role: "tool", string content or text chunks
LangChain.jsToolMessage and FunctionMessage content, a string or text blocks; with ShieldCallbackHandler, tool outputs and retrieved documents as well
MCP clientWhat callTool returns (text blocks, embedded resources, resource link titles and descriptions, structuredContent, and a legacy toolResult), the contents of readResource, and the description and messages of getPrompt. Tool lists from listTools are checked with scanTools().
OpenAI Agents SDKWith shieldToolOutputGuardrail, what a function tool or MCP server tool returns (a string, text parts, MCP content blocks, or the string values of anything else) before the model reads it; the model gets a rejection message in its place instead of the run throwing. With shieldInputGuardrail, the function_call_result, shell_call_output, apply_patch_call_output, program_output, and hosted_tool_call items in the run's input.

Each tool result is scanned separately, with its text parts joined. For a JSON result, string values are joined with newlines, truncated at 64K characters, and scanned; keys are not scanned. Images, PDFs, and documents given by URL or file ID are not read.

Tool results use the detect options unless scanToolResults has its own. Documents and tool output often contain instructions meant for people, such as setup steps or policy text, so if you see false positives there, pass scanToolResults: { classifier: { threshold: 0.8 } } or other detect options rather than turning detection off for user messages too.

Parallel detection

By default, a wrapper waits for escalate before sending the request, so a slow detector, such as a transformer model or an LLM judge, adds its latency to every call. With parallelDetection: true, the fast check still runs first and blocks what it finds before the request is sent. Then escalate runs alongside the model call, and the wrapper holds the response until all verdicts are available:

  • Clean: you get the response as usual.
  • Injection: the call throws InjectionDetectedError, the same error it would have thrown before sending the request, and discards the response. A stream is stopped: the OpenAI and Anthropic wrappers abort the request, Mistral's stream is cancelled, and Google's is closed. With onDetection: "warn", onInjectionDetected is called when the verdict arrives, and you receive the response.
  • A detector throws: the call rejects with its error.
import OpenAI from "openai";
import { anyOf, createLlmDetector } from "@zeroleaks/shield";
import { createModelDetector } from "@zeroleaks/shield/model";
import { shieldOpenAI } from "@zeroleaks/shield/openai";

const model = createModelDetector();
const judge = createLlmDetector({
  url: "https://api.openai.com/v1/chat/completions",
  model: "gpt-5.5",
  apiKey: process.env.OPENAI_API_KEY,
});

const client = shieldOpenAI(new OpenAI(), {
  detect: { escalate: { minScore: 0, detector: anyOf(model, judge) } },
  parallelDetection: true,
});

A request then takes about as long as the slower of the model call and the slow detectors, rather than their combined duration. The trade-offs:

  • The model call is made, and billed, even when the slow check then blocks the request.
  • Nothing from the response reaches your code before the verdict, including tool calls, so your code never runs a tool the model asked for in a blocked response. Google Gen AI's automatic function calling runs callable tools inside generateContent(); the wrapper holds those calls until the verdict is in, and drops them if it blocks.
  • Tools the provider runs itself are not held. OpenAI's hosted tools and remote MCP servers, and Anthropic's server tools, run during the call, before the verdict. Leave parallelDetection off for requests that use them.
  • A secondaryDetector still runs before the call. It only runs on text the fast check flagged, and that text never reaches the model unless the verifier clears it.
  • Slow checks for every applicable message start at once. Without the option, messages are checked one after another. A long history therefore makes many concurrent calls to the detector.

Errors are thrown from the call, as without the option: create(), generateContent(), generateContentStream(), chat.complete(), chat.stream(), or LangChain's invoke() and stream(). With stream: true, the call resolves to the stream only once the verdict is in, so your for await loop never sees a chunk of a blocked response. In "buffer" mode the wrapper reads the whole stream while the slow check runs, and stops reading if the verdict blocks first. In "chunked" and "passthrough" modes, and in every mode of the OpenAI Responses API, the stream is handed over once the verdict is in, so the first chunk arrives after the slower of the model's first token and the verdict. shieldChatModel also holds the tokens the model streams to callback handlers until then.

parallelDetection applies to the OpenAI, Groq, Anthropic, Google Gen AI, and Mistral wrappers and to shieldChatModel. The AI SDK middleware never runs escalate, so it changes nothing there.

Output guard

After the prompt leak check, the wrappers run scanOutputText() on the model's output and redact what it finds. By default that is credentials and links that send data out when rendered or clicked. Pass output: { pii: true } to redact personal data as well, or any other output scanning option, such as exfiltration: { allowedDomains: [...] }.

What is guarded:

  • Text: OpenAI message.content for every choice, Responses output_text parts (and the output_text property), Anthropic text blocks, the text parts of every Google Gen AI candidate, Mistral message content, LangChain.js AI message text, and AI SDK text.
  • Tool call arguments: OpenAI function.arguments and custom tool input for every tool call, and the legacy function_call.arguments; Responses function_call arguments and custom_tool_call input; every string in an Anthropic tool_use block's input; every string in a Google Gen AI functionCall part's args; Mistral function.arguments, whether a string or an object; every string in a LangChain.js tool_calls entry's args, and the other copies of it; AI SDK tool call input (AI SDK 5 and later) or args (AI SDK 4), whether a string or an object. An agent tricked into sending a secret to a tool is caught here, before the tool runs.

A prompt leak and an output finding that overlap are replaced with one redaction. The redaction text is output.redactionText, else sanitize.redactionText, else [REDACTED]; a redaction that only covers a prompt leak uses sanitize.redactionText. If output.redactionText is a function, it is called with the first output finding in the range and every finding merged into it, which can include a prompt_leak finding for the system prompt text. When text is redacted, OpenAI logprobs for it are removed, since they list the original tokens.

In an argument object, such as an Anthropic tool_use input or a Google Gen AI functionCall's args, keys are scanned as well as values. A key with a finding is replaced by its redacted form, with _2, _3, and so on added if that would collide with another key.

onOutputFindings is called once per scan: a text block, an argument string, a whole argument object, or a chunk in "chunked" mode. Each finding's start and end are offsets in the string it was found in, or in the whole streamed text in "chunked" mode. Its preview is safe to log.

With blockOnOutputFindings: true, a high or critical finding throws OutputBlockedError instead of redacting. Its findings hold the type, kind, and severity of every finding in that scan, never the matched text. If the same scan found a prompt leak or a canary and throwOnLeak is set, LeakDetectedError is thrown instead.

With output: false and no canary, and no system prompt to check against (or sanitize: false), the wrapper leaves the output alone.

Canary

A canary is a random token in the system prompt that has no reason to appear in output. With canary: true, the wrapper generates one with createCanary() when the wrapper is created; you can also pass your own token as a string. The wrapper adds it to every system prompt it hardens, and any appearance of it in output counts as a leak: onLeakDetected is called, the token is redacted, and with throwOnLeak the call throws LeakDetectedError. Detection also finds encoded or spaced versions of the token.

  • The canary is planted only where there is a system prompt to harden: system and developer messages, Anthropic's system, Google Gen AI's config.systemInstruction, and the OpenAI Responses API's instructions. A request without one gets no canary.
  • With harden: false, nothing is planted, but output is still checked for the token. Use this when you put the canary in your prompt yourself.
  • A canary in harden: { canary } is checked for as well.
  • Every canary you pass, in canary, harden.canary, or output.canary, is checked when the wrapper is created, with the rules findCanary() uses: it must be a single token of letters, digits, hyphens, or underscores, or the wrapper throws a TypeError, and it must have at least 6 letters or digits, or it throws a RangeError. The message names the option. canary: true needs globalThis.crypto.getRandomValues (Node 19 or later, or Node 18 with --experimental-global-webcrypto) and throws without it.

Streaming

With stream: true, the wrappers guard the output before your code sees it. streamingSanitize controls how. The prompt leak check and the output guard run together, in every mode except "passthrough".

ModeBehavior
"buffer" (default)Reads the whole stream, guards the full text and tool call arguments, then replays the provider's chunks. Nothing reaches your code until the model finishes.
"chunked"Guards the text and tool call arguments every streamingChunkSize characters (8,192 by default) and replays the provider's chunks as they come. Uses less memory, but yielded text can't be taken back.
"passthrough"Returns the provider's stream unchanged, with no sanitization or output scanning.

Because the output guard is on by default, every stream is guarded unless you pass "passthrough" or turn off both checks, even when the request has no system prompt. In "buffer" mode that means the first chunk arrives only after the model finishes. If text has to appear as it is generated, use "chunked" mode with a smaller streamingChunkSize, with the limits described below.

"buffer" mode keeps every chunk. The replay is the provider's own chunks and events, in order: role, tool call deltas, finish reasons, usage, and message start and stop events all come through. If nothing was redacted, you get the original objects. If a text was redacted, it goes out in 64-character pieces where its first non-empty delta was and its later deltas are emptied (OpenAI) or dropped (Anthropic). A redacted tool call argument string goes out whole in its first delta. For OpenAI, each choice's text and each tool call's arguments are guarded; for Anthropic, each content block's text or tool input JSON. The OpenAI Responses API holds text differently; see its streaming section.

"chunked" mode keeps every chunk too. Each text and each tool call's arguments go through a chunked scan of their own, and the provider's chunks are replayed with the guarded text in place of the original:

  • OpenAI and Groq: each choice's text and each tool call's arguments. Chunks go out one behind, and what a choice still holds back is added to the chunk with its finish reason, or to the last chunk. A choice whose text was rewritten has its logprobs set to null.
  • Anthropic: each content block's text or tool input JSON. A delta event whose text is all held back is left out, and what a block still holds back goes out in one more delta event just before its content_block_stop.

Every other chunk or event comes through in order. Each chunk is scanned together with the text sent just before it, and its end is held back and scanned again with the next one: 512 characters with the output guard on (or streamingChunkSize, if that is smaller, but at least 64), and 64 with it off. A finding that crosses a boundary is caught from either side, and no text is repeated. A redaction that runs to the end of a chunk is held back and scanned again whole with the next one, up to 32K characters, so a private key split by a boundary is redacted in full. A leak that starts before the held-back text and that its own chunk doesn't flag still goes out in part. throwOnLeak only throws after the last chunk has been yielded; blockOnOutputFindings throws as soon as the chunk with the finding arrives, before its text is yielded.

If the provider's stream fails partway through, the wrapper propagates the error. In "buffer" mode, create() rejects with it. In "chunked" mode, your for await loop throws it, and buffered text is never yielded.

What is not scanned

  • Input: assistant and system messages; images, audio, PDFs, and documents given by URL or file ID; the keys of JSON tool results, and their string values past the first 64K characters (a tool result that is a string is scanned whole, up to detect's maxInputLength); with OpenAI's previous_response_id or conversation, the earlier items stored by OpenAI, which the wrapper never sees.
  • Output: reasoning and thinking text, refusals, citations, and provider metadata. OpenAI Responses refusal parts, reasoning items, and the arguments of MCP calls OpenAI runs itself are passed through as they are.
  • Streams: everything in "passthrough" mode.
  • Other methods: anything sent or received through a method other than the wrapped create, such as responses.stream() or messages.stream().

On this page