ai-sdk-openai-guardrails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/dist/index.cjs +397 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +90 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.js +368 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `guardrailsMiddleware()` runs an OpenAI Guardrails pipeline bundle as AI SDK language model middleware, against any provider.
|
|
8
|
+
- Pre-flight and input stages run in `transformParams`, so a tripwire fails the call before the provider is billed and pre-flight PII redactions reach the provider already masked.
|
|
9
|
+
- Output stage runs in `wrapGenerate` and, for streams, behind a gate with three modes: `buffer`, `chunk`, and `off`.
|
|
10
|
+
- Tool calls and tool results from the AI SDK prompt are converted into the conversation history that `Prompt Injection Detection` and the other history-aware checks read.
|
|
11
|
+
- Checks that fail to execute block the call by default (`onCheckError: 'throw'`).
|
|
12
|
+
- One build supports `ai@6` (provider spec v3) and `ai@7` (spec v4).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sokratis Vidros
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# ai-sdk-openai-guardrails
|
|
2
|
+
|
|
3
|
+
Run [OpenAI Guardrails](https://github.com/openai/openai-guardrails-js) inside the [Vercel AI SDK](https://ai-sdk.dev), as language model middleware. Works with any AI SDK provider, not just OpenAI.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const model = wrapLanguageModel({
|
|
7
|
+
model: anthropic('claude-sonnet-4-5'),
|
|
8
|
+
middleware: guardrailsMiddleware({ config, guardrailLlm: new OpenAI() }),
|
|
9
|
+
});
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Moderation, jailbreak detection, PII redaction, secret-key leak detection, and prompt-injection detection then run on every `generateText`, `streamText`, and agent step that uses that model.
|
|
13
|
+
|
|
14
|
+
## Why this package exists
|
|
15
|
+
|
|
16
|
+
`@openai/guardrails` ships two layers. The first is `GuardrailsOpenAI`, a drop-in replacement for the `openai` client. It cannot work with the AI SDK: AI SDK providers talk to model APIs through their own `fetch` and never take an `openai` client. `GuardrailAgent` is bound to `@openai/agents` for the same reason.
|
|
17
|
+
|
|
18
|
+
The layer underneath is a model-agnostic runtime that operates on plain text. This package plugs that runtime into the AI SDK middleware lifecycle, and does the work the drop-in client does for OpenAI's own request shapes, which nobody does for the AI SDK:
|
|
19
|
+
|
|
20
|
+
- Map the bundle's three stages onto `transformParams`, `wrapGenerate`, and `wrapStream`.
|
|
21
|
+
- Apply pre-flight PII redactions back into the prompt, so the provider receives masked text.
|
|
22
|
+
- Convert an AI SDK prompt (tool calls and tool results included) into the conversation history that the history-aware checks read. Without this, `Prompt Injection Detection` has no tool traffic to inspect.
|
|
23
|
+
- Run the output stage against a stream without reordering its parts.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install ai-sdk-openai-guardrails @openai/guardrails
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`ai` and `@openai/guardrails` are peer dependencies. The package itself has no runtime dependencies.
|
|
32
|
+
|
|
33
|
+
| Peer | Range |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `ai` | `^6.0.0 \|\| ^7.0.0` |
|
|
36
|
+
| `@openai/guardrails` | `>=0.2.0 <1` |
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
Create a pipeline bundle with the wizard at [guardrails.openai.com](https://guardrails.openai.com), or write it by hand:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { openai } from '@ai-sdk/openai';
|
|
44
|
+
import { generateText, wrapLanguageModel } from 'ai';
|
|
45
|
+
import OpenAI from 'openai';
|
|
46
|
+
import { GuardrailTripwireError, guardrailsMiddleware } from 'ai-sdk-openai-guardrails';
|
|
47
|
+
|
|
48
|
+
const model = wrapLanguageModel({
|
|
49
|
+
model: openai('gpt-5'),
|
|
50
|
+
middleware: guardrailsMiddleware({
|
|
51
|
+
config: {
|
|
52
|
+
version: 1,
|
|
53
|
+
pre_flight: {
|
|
54
|
+
version: 1,
|
|
55
|
+
guardrails: [
|
|
56
|
+
{
|
|
57
|
+
name: 'Contains PII',
|
|
58
|
+
config: {
|
|
59
|
+
entities: ['US_SSN', 'EMAIL_ADDRESS'],
|
|
60
|
+
block: false,
|
|
61
|
+
detect_encoded_pii: true,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
input: {
|
|
67
|
+
version: 1,
|
|
68
|
+
guardrails: [
|
|
69
|
+
{ name: 'Moderation', config: { categories: ['hate', 'violence'] } },
|
|
70
|
+
{ name: 'Jailbreak', config: { model: 'gpt-5-mini', confidence_threshold: 0.7 } },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
output: {
|
|
74
|
+
version: 1,
|
|
75
|
+
guardrails: [{ name: 'Secret Keys', config: { threshold: 'balanced' } }],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
guardrailLlm: new OpenAI(),
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const { text } = await generateText({ model, prompt: 'My SSN is 123-45-6789. Help me file.' });
|
|
84
|
+
console.log(text);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (GuardrailTripwireError.isInstance(error)) {
|
|
87
|
+
console.error(`Blocked at ${error.stage} by ${error.guardrailNames.join(', ')}`);
|
|
88
|
+
} else {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The model sees `My SSN is <US_SSN>. Help me file.` A tripwire at any stage throws `GuardrailTripwireError`.
|
|
95
|
+
|
|
96
|
+
`config` also accepts the bundle as a JSON string, so a file read or an environment variable can be passed straight in.
|
|
97
|
+
|
|
98
|
+
## How the stages map
|
|
99
|
+
|
|
100
|
+
| Bundle stage | Middleware hook | Runs on | Effect |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| `pre_flight` | `transformParams` | Prompt text | Redactions are applied to the prompt before the provider call. A tripwire fails the call. |
|
|
103
|
+
| `input` | `transformParams` | Prompt text, after redaction | A tripwire fails the call, before the provider is billed. |
|
|
104
|
+
| `output` | `wrapGenerate` / `wrapStream` | Generated text | A tripwire fails the call or errors the stream. |
|
|
105
|
+
|
|
106
|
+
Unused stages are skipped. They are not instantiated and they cost nothing.
|
|
107
|
+
|
|
108
|
+
### What counts as prompt text
|
|
109
|
+
|
|
110
|
+
By default the pre-flight and input stages see the last user message, matching the OpenAI drop-in client. Set `inputScope: 'all-messages'` to cover every user and assistant turn plus all tool output, excluding the system prompt. Tool output is a common injection vector. Use `all-messages` with `Prompt Injection Detection`.
|
|
111
|
+
|
|
112
|
+
## Streaming
|
|
113
|
+
|
|
114
|
+
Output guardrails need a block of text before they can run. `stream.mode` chooses how that delay is handled.
|
|
115
|
+
|
|
116
|
+
| Mode | Behavior | Use when |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `buffer` (default) | Holds every part, checks the completed text once, then releases. | No unchecked text may reach the reader. |
|
|
119
|
+
| `chunk` | Checks the text accumulated so far every `chunkChars` characters (default 200) and releases the parts behind each passing check. | You want tokens to arrive as they are generated, and can accept that a late tripwire arrives after some text has already gone out. |
|
|
120
|
+
| `off` | Passes the stream through unchecked. Input stages still run. | Output checks belong somewhere else in your stack. |
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
guardrailsMiddleware({ config, guardrailLlm, stream: { mode: 'chunk', chunkChars: 200 } });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
In `chunk` mode each check runs on the whole block accumulated so far, not just the new characters, so an LLM-backed output check is called repeatedly on growing text. Prefer the local checks (`Secret Keys`, `Contains PII`, `Competitors`, `Keyword Filter`) there.
|
|
127
|
+
|
|
128
|
+
Parts are always released in arrival order, and only once every character of text ahead of them has passed, so a tool call a provider interleaves with text can never overtake that text. A tripwire errors the stream, which cancels the upstream request.
|
|
129
|
+
|
|
130
|
+
## Cost of each guardrail
|
|
131
|
+
|
|
132
|
+
Names and configuration come from `@openai/guardrails`, not from this package. The difference that matters for cost is which checks call OpenAI.
|
|
133
|
+
|
|
134
|
+
| Guardrail | Engine | Calls OpenAI |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `Keyword Filter` | regex | no |
|
|
137
|
+
| `Competitors` | regex | no |
|
|
138
|
+
| `Secret Keys` | regex | no |
|
|
139
|
+
| `URL Filter` | regex plus URL parsing | no |
|
|
140
|
+
| `Contains PII` | regex | no |
|
|
141
|
+
| `Moderation` | Moderation API | yes |
|
|
142
|
+
| `Jailbreak` | LLM | yes |
|
|
143
|
+
| `NSFW Text` | LLM | yes |
|
|
144
|
+
| `Off Topic Prompts` | LLM | yes |
|
|
145
|
+
| `Custom Prompt Check` | LLM | yes |
|
|
146
|
+
| `Prompt Injection Detection` | LLM | yes |
|
|
147
|
+
| `Hallucination Detection` | Responses API file search | yes, plus a vector store |
|
|
148
|
+
|
|
149
|
+
The local checks add no network call and no latency worth measuring. The rest cost one OpenAI call per stage per model call, including when the answering model is Anthropic, Google, or a local one.
|
|
150
|
+
|
|
151
|
+
`Contains PII` in the JavaScript port is regex-based and needs no Presidio service, unlike the Python original.
|
|
152
|
+
|
|
153
|
+
## Options
|
|
154
|
+
|
|
155
|
+
| Option | Default | What it does |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `config` | required | Pipeline bundle, as an object or a JSON string. |
|
|
158
|
+
| `guardrailLlm` | required | An `openai` client for the checks that call OpenAI. Typed structurally, so any `openai` major works. |
|
|
159
|
+
| `inputScope` | `'latest-user'` | `'latest-user'` or `'all-messages'`. See above. |
|
|
160
|
+
| `maskInput` | `true` | Apply pre-flight redactions to the prompt. |
|
|
161
|
+
| `stream.mode` | `'buffer'` | `'buffer'`, `'chunk'` or `'off'`. |
|
|
162
|
+
| `stream.chunkChars` | `200` | Characters of new text per check in `chunk` mode. |
|
|
163
|
+
| `onCheckError` | `'throw'` | `'throw'` or `'ignore'`. See below. |
|
|
164
|
+
| `onResults` | none | Called once per stage that ran, tripwire or not. Awaited. |
|
|
165
|
+
| `context` | none | Extra fields merged into the guardrail context, for custom checks. |
|
|
166
|
+
|
|
167
|
+
### Failing closed
|
|
168
|
+
|
|
169
|
+
If a check cannot run (the Moderation API is down, or the guardrail model rejects the request), the default is to block the call and throw `GuardrailCheckFailedError`. A pipeline that cannot decide whether content is safe does not pass it through.
|
|
170
|
+
|
|
171
|
+
`@openai/guardrails` defaults to the opposite: `runGuardrails` swallows the failure and returns `executionFailed: true` with the tripwire clear. Set `onCheckError: 'ignore'` for that behavior, and read `event.results[].executionFailed` in `onResults` to see what happened.
|
|
172
|
+
|
|
173
|
+
### Observability
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
guardrailsMiddleware({
|
|
177
|
+
config,
|
|
178
|
+
guardrailLlm,
|
|
179
|
+
onResults: ({ stage, text, results, triggered }) => {
|
|
180
|
+
metrics.increment('guardrails.stage', { stage, triggered: triggered.length });
|
|
181
|
+
for (const result of results) {
|
|
182
|
+
if (result.executionFailed) {
|
|
183
|
+
logger.warn({ guardrail: result.info?.guardrail_name }, 'guardrail could not run');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`onResults` is awaited, so an error thrown there fails the model call.
|
|
191
|
+
|
|
192
|
+
## Agent loops and tool injection
|
|
193
|
+
|
|
194
|
+
The middleware runs per model call. In a multi-step agent loop that is once per step.
|
|
195
|
+
|
|
196
|
+
Tool output is checked on later steps: by step two, the first step's tool results are in the prompt, so an input-stage check sees them. `Prompt Injection Detection` depends on this. The middleware converts the AI SDK prompt into `function_call` and `function_call_output` conversation entries, which is the shape the check matches on.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
guardrailsMiddleware({
|
|
200
|
+
config: {
|
|
201
|
+
version: 1,
|
|
202
|
+
input: {
|
|
203
|
+
version: 1,
|
|
204
|
+
guardrails: [
|
|
205
|
+
{
|
|
206
|
+
name: 'Prompt Injection Detection',
|
|
207
|
+
config: { model: 'gpt-5-mini', confidence_threshold: 0.7 },
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
guardrailLlm: new OpenAI(),
|
|
213
|
+
inputScope: 'all-messages',
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The input stage also repeats. A four-step agent run means four input-stage passes. With LLM-backed checks that is four extra OpenAI calls. You can accept that cost, run the expensive checks once at your application boundary with `runGuardrails`, or keep the middleware bundle to the local checks.
|
|
218
|
+
|
|
219
|
+
## Errors
|
|
220
|
+
|
|
221
|
+
Both error classes carry a `Symbol.for` marker, so `isInstance` still works when duplicate copies of this package share a dependency tree. `instanceof` does not.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { GuardrailCheckFailedError, GuardrailTripwireError } from 'ai-sdk-openai-guardrails';
|
|
225
|
+
|
|
226
|
+
if (GuardrailTripwireError.isInstance(error)) {
|
|
227
|
+
error.stage; // 'pre_flight' | 'input' | 'output'
|
|
228
|
+
error.guardrailNames; // ['Jailbreak']
|
|
229
|
+
error.results; // the GuardrailResult objects that fired, with their info payloads
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (GuardrailCheckFailedError.isInstance(error)) {
|
|
233
|
+
error.stage;
|
|
234
|
+
error.guardrailName;
|
|
235
|
+
error.cause; // the underlying failure
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Helpers
|
|
240
|
+
|
|
241
|
+
Exported for anyone running checks outside the middleware, against `runGuardrails` directly:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import {
|
|
245
|
+
extractContentText,
|
|
246
|
+
extractPromptText,
|
|
247
|
+
toGuardrailConversation,
|
|
248
|
+
} from 'ai-sdk-openai-guardrails';
|
|
249
|
+
|
|
250
|
+
extractPromptText(prompt, 'latest-user'); // string
|
|
251
|
+
extractContentText(result.content); // string
|
|
252
|
+
toGuardrailConversation(prompt); // conversation entries for the history-aware checks
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## AI SDK version support
|
|
256
|
+
|
|
257
|
+
One build serves `ai@6` (provider spec v3) and `ai@7` (spec v4). Prompt text, tool parts, the stream envelope, and the middleware hooks are the same in both specs, so the types are structural instead of imported from a pinned `@ai-sdk/provider`. `src/compat.test.ts` checks assignability both ways and runs the middleware through `ai@6` `wrapLanguageModel` and `generateText`.
|
|
258
|
+
|
|
259
|
+
## Limitations
|
|
260
|
+
|
|
261
|
+
- Output checks look at text. Tool call arguments are not checked.
|
|
262
|
+
- Once `chunk` mode has released text it cannot recall it. Only `buffer` mode guarantees that nothing unchecked reaches the reader.
|
|
263
|
+
- Pre-flight redaction rewrites text parts and the system prompt, never tool results: those are JSON the model has to parse, and masking them would corrupt the payload.
|
|
264
|
+
- `Hallucination Detection` needs a vector store you have already populated. This package forwards that config. It does not create a vector store.
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
GuardrailCheckFailedError: () => GuardrailCheckFailedError,
|
|
24
|
+
GuardrailTripwireError: () => GuardrailTripwireError,
|
|
25
|
+
extractContentText: () => extractContentText,
|
|
26
|
+
extractPromptText: () => extractPromptText,
|
|
27
|
+
guardrailsMiddleware: () => guardrailsMiddleware,
|
|
28
|
+
toGuardrailConversation: () => toGuardrailConversation
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
|
|
32
|
+
// src/errors.ts
|
|
33
|
+
var tripwireMarker = /* @__PURE__ */ Symbol.for("ai-sdk-openai-guardrails.tripwire");
|
|
34
|
+
var checkFailedMarker = /* @__PURE__ */ Symbol.for("ai-sdk-openai-guardrails.check-failed");
|
|
35
|
+
function hasMarker(error, marker) {
|
|
36
|
+
return error instanceof Error && marker in error;
|
|
37
|
+
}
|
|
38
|
+
function namesOf(results) {
|
|
39
|
+
return results.map((result) => String(result.info?.guardrail_name ?? "unknown"));
|
|
40
|
+
}
|
|
41
|
+
var GuardrailTripwireError = class extends Error {
|
|
42
|
+
[tripwireMarker] = true;
|
|
43
|
+
stage;
|
|
44
|
+
results;
|
|
45
|
+
constructor(stage, results) {
|
|
46
|
+
super(`Guardrail tripwire triggered at the ${stage} stage: ${namesOf(results).join(", ")}`);
|
|
47
|
+
this.name = "GuardrailTripwireError";
|
|
48
|
+
this.stage = stage;
|
|
49
|
+
this.results = results;
|
|
50
|
+
}
|
|
51
|
+
get guardrailNames() {
|
|
52
|
+
return namesOf(this.results);
|
|
53
|
+
}
|
|
54
|
+
static isInstance(error) {
|
|
55
|
+
return hasMarker(error, tripwireMarker);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var GuardrailCheckFailedError = class extends Error {
|
|
59
|
+
[checkFailedMarker] = true;
|
|
60
|
+
stage;
|
|
61
|
+
guardrailName;
|
|
62
|
+
constructor(stage, guardrailName, cause) {
|
|
63
|
+
super(`Guardrail '${guardrailName}' failed to run at the ${stage} stage`, { cause });
|
|
64
|
+
this.name = "GuardrailCheckFailedError";
|
|
65
|
+
this.stage = stage;
|
|
66
|
+
this.guardrailName = guardrailName;
|
|
67
|
+
}
|
|
68
|
+
static isInstance(error) {
|
|
69
|
+
return hasMarker(error, checkFailedMarker);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// src/pipeline.ts
|
|
74
|
+
var import_guardrails = require("@openai/guardrails");
|
|
75
|
+
var STAGES = ["pre_flight", "input", "output"];
|
|
76
|
+
function createStageRunner(config, onCheckError) {
|
|
77
|
+
let stagesPromise;
|
|
78
|
+
function stages() {
|
|
79
|
+
stagesPromise ??= (async () => {
|
|
80
|
+
const pipeline = await (0, import_guardrails.loadPipelineBundles)(config);
|
|
81
|
+
const entries = await Promise.all(
|
|
82
|
+
STAGES.map(async (stage) => {
|
|
83
|
+
const bundle = pipeline[stage];
|
|
84
|
+
const guardrails = bundle?.guardrails?.length ? await (0, import_guardrails.instantiateGuardrails)(bundle) : [];
|
|
85
|
+
return [stage, guardrails];
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
return Object.fromEntries(entries);
|
|
89
|
+
})();
|
|
90
|
+
return stagesPromise;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
async isEmpty(stage) {
|
|
94
|
+
return (await stages())[stage].length === 0;
|
|
95
|
+
},
|
|
96
|
+
async run(stage, text, context) {
|
|
97
|
+
const guardrails = (await stages())[stage];
|
|
98
|
+
if (guardrails.length === 0) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
return Promise.all(
|
|
102
|
+
guardrails.map(async (guardrail) => {
|
|
103
|
+
const name = guardrail.definition.name;
|
|
104
|
+
let result;
|
|
105
|
+
try {
|
|
106
|
+
result = await guardrail.run(context, text);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (onCheckError === "throw") {
|
|
109
|
+
throw new GuardrailCheckFailedError(stage, name, error);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
tripwireTriggered: false,
|
|
113
|
+
executionFailed: true,
|
|
114
|
+
originalException: error instanceof Error ? error : new Error(String(error)),
|
|
115
|
+
info: { stage_name: stage, guardrail_name: name }
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (result.executionFailed && onCheckError === "throw") {
|
|
119
|
+
throw new GuardrailCheckFailedError(stage, name, result.originalException);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
...result,
|
|
123
|
+
info: { stage_name: stage, guardrail_name: name, ...result.info }
|
|
124
|
+
};
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/prompt.ts
|
|
132
|
+
var ZERO_WIDTH = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g;
|
|
133
|
+
function normalize(text) {
|
|
134
|
+
return text.normalize("NFKC").replace(ZERO_WIDTH, "");
|
|
135
|
+
}
|
|
136
|
+
function joinTextParts(message) {
|
|
137
|
+
if (message.role === "system") {
|
|
138
|
+
return message.content;
|
|
139
|
+
}
|
|
140
|
+
const chunks = [];
|
|
141
|
+
for (const part of message.content) {
|
|
142
|
+
if (part.type === "text" && part.text !== void 0) {
|
|
143
|
+
chunks.push(part.text);
|
|
144
|
+
} else if (part.type === "tool-result" && part.output !== void 0) {
|
|
145
|
+
chunks.push(stringifyToolOutput(part.output));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return chunks.join("\n");
|
|
149
|
+
}
|
|
150
|
+
function stringifyToolOutput(output) {
|
|
151
|
+
if ("value" in output) {
|
|
152
|
+
return typeof output.value === "string" ? output.value : JSON.stringify(output.value);
|
|
153
|
+
}
|
|
154
|
+
return JSON.stringify(output);
|
|
155
|
+
}
|
|
156
|
+
function stringifyToolInput(input) {
|
|
157
|
+
return typeof input === "string" ? input : JSON.stringify(input ?? {});
|
|
158
|
+
}
|
|
159
|
+
function extractPromptText(prompt, scope) {
|
|
160
|
+
if (scope === "latest-user") {
|
|
161
|
+
for (let i = prompt.length - 1; i >= 0; i--) {
|
|
162
|
+
const message = prompt[i];
|
|
163
|
+
if (message?.role === "user") {
|
|
164
|
+
return joinTextParts(message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
return prompt.filter((message) => message.role !== "system").map(joinTextParts).filter((text) => text.length > 0).join("\n");
|
|
170
|
+
}
|
|
171
|
+
function extractContentText(content) {
|
|
172
|
+
return content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("");
|
|
173
|
+
}
|
|
174
|
+
function toGuardrailConversation(prompt) {
|
|
175
|
+
const entries = [];
|
|
176
|
+
for (const message of prompt) {
|
|
177
|
+
if (message.role === "system") {
|
|
178
|
+
entries.push({ role: "system", content: message.content });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
for (const part of message.content) {
|
|
182
|
+
if (part.type === "text") {
|
|
183
|
+
entries.push({ role: message.role, content: part.text ?? "" });
|
|
184
|
+
} else if (part.type === "tool-call") {
|
|
185
|
+
entries.push({
|
|
186
|
+
type: "function_call",
|
|
187
|
+
tool_name: part.toolName,
|
|
188
|
+
arguments: stringifyToolInput(part.input),
|
|
189
|
+
call_id: part.toolCallId
|
|
190
|
+
});
|
|
191
|
+
} else if (part.type === "tool-result" && part.output !== void 0) {
|
|
192
|
+
entries.push({
|
|
193
|
+
type: "function_call_output",
|
|
194
|
+
tool_name: part.toolName,
|
|
195
|
+
output: stringifyToolOutput(part.output),
|
|
196
|
+
call_id: part.toolCallId
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return entries;
|
|
202
|
+
}
|
|
203
|
+
function buildMask(results, checkedText) {
|
|
204
|
+
const mappings = [];
|
|
205
|
+
let checkedTextOverride;
|
|
206
|
+
for (const result of results) {
|
|
207
|
+
const detected = result.info?.detected_entities;
|
|
208
|
+
if (!detected || typeof detected !== "object") {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
for (const [entityType, entities] of Object.entries(detected)) {
|
|
212
|
+
if (!Array.isArray(entities)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
for (const entity of entities) {
|
|
216
|
+
mappings.push([normalize(String(entity)), `<${entityType}>`]);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (typeof result.info?.checked_text === "string" && checkedTextOverride === void 0) {
|
|
220
|
+
checkedTextOverride = result.info.checked_text;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (mappings.length === 0 && checkedTextOverride === void 0) {
|
|
224
|
+
return void 0;
|
|
225
|
+
}
|
|
226
|
+
mappings.sort((a, b) => b[0].length - a[0].length);
|
|
227
|
+
return (text) => {
|
|
228
|
+
const normalized = normalize(text);
|
|
229
|
+
let masked = normalized;
|
|
230
|
+
for (const [entity, token] of mappings) {
|
|
231
|
+
if (entity && masked.includes(entity)) {
|
|
232
|
+
masked = masked.split(entity).join(token);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (masked !== normalized) {
|
|
236
|
+
return masked;
|
|
237
|
+
}
|
|
238
|
+
if (checkedTextOverride !== void 0 && text === checkedText) {
|
|
239
|
+
return checkedTextOverride;
|
|
240
|
+
}
|
|
241
|
+
return text;
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function maskPrompt(prompt, mask) {
|
|
245
|
+
const masked = prompt.map((message) => {
|
|
246
|
+
if (message.role === "system") {
|
|
247
|
+
return { ...message, content: mask(message.content) };
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
...message,
|
|
251
|
+
content: message.content.map(
|
|
252
|
+
(part) => part.type === "text" && part.text !== void 0 ? { ...part, text: mask(part.text) } : part
|
|
253
|
+
)
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
return masked;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/stream.ts
|
|
260
|
+
function createOutputCheckTransform(options) {
|
|
261
|
+
const { mode, chunkChars, check } = options;
|
|
262
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
263
|
+
let pending = [];
|
|
264
|
+
function unchecked() {
|
|
265
|
+
let total = 0;
|
|
266
|
+
for (const block of blocks.values()) {
|
|
267
|
+
total += block.text.length - block.checked;
|
|
268
|
+
}
|
|
269
|
+
return total;
|
|
270
|
+
}
|
|
271
|
+
async function checkOpenBlocks() {
|
|
272
|
+
for (const block of blocks.values()) {
|
|
273
|
+
if (block.text.length > block.checked) {
|
|
274
|
+
await check(block.text);
|
|
275
|
+
block.checked = block.text.length;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function flush(controller) {
|
|
280
|
+
for (const part of pending) {
|
|
281
|
+
controller.enqueue(part);
|
|
282
|
+
}
|
|
283
|
+
pending = [];
|
|
284
|
+
}
|
|
285
|
+
return new TransformStream({
|
|
286
|
+
async transform(part, controller) {
|
|
287
|
+
pending.push(part);
|
|
288
|
+
const id = part.id ?? "";
|
|
289
|
+
switch (part.type) {
|
|
290
|
+
case "text-start":
|
|
291
|
+
blocks.set(id, { text: "", checked: 0 });
|
|
292
|
+
break;
|
|
293
|
+
case "text-delta": {
|
|
294
|
+
const block = blocks.get(id) ?? { text: "", checked: 0 };
|
|
295
|
+
block.text += part.delta ?? "";
|
|
296
|
+
blocks.set(id, block);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
default:
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
const boundary = part.type === "text-end" || part.type === "finish";
|
|
303
|
+
if (boundary || mode === "chunk" && unchecked() >= chunkChars) {
|
|
304
|
+
await checkOpenBlocks();
|
|
305
|
+
}
|
|
306
|
+
if (part.type === "text-end") {
|
|
307
|
+
blocks.delete(id);
|
|
308
|
+
}
|
|
309
|
+
if (unchecked() === 0) {
|
|
310
|
+
flush(controller);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
async flush(controller) {
|
|
314
|
+
await checkOpenBlocks();
|
|
315
|
+
flush(controller);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/middleware.ts
|
|
321
|
+
var DEFAULT_CHUNK_CHARS = 200;
|
|
322
|
+
function guardrailsMiddleware(options) {
|
|
323
|
+
const {
|
|
324
|
+
config,
|
|
325
|
+
guardrailLlm,
|
|
326
|
+
context: extraContext,
|
|
327
|
+
inputScope = "latest-user",
|
|
328
|
+
maskInput = true,
|
|
329
|
+
onCheckError = "throw",
|
|
330
|
+
onResults
|
|
331
|
+
} = options;
|
|
332
|
+
const streamMode = options.stream?.mode ?? "buffer";
|
|
333
|
+
const chunkChars = options.stream?.chunkChars ?? DEFAULT_CHUNK_CHARS;
|
|
334
|
+
const runner = createStageRunner(config, onCheckError);
|
|
335
|
+
async function runStage(stage, text, prompt) {
|
|
336
|
+
if (text.length === 0 || await runner.isEmpty(stage)) {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
const conversationHistory = toGuardrailConversation(prompt);
|
|
340
|
+
const results = await runner.run(stage, text, {
|
|
341
|
+
...extraContext,
|
|
342
|
+
guardrailLlm,
|
|
343
|
+
conversationHistory,
|
|
344
|
+
getConversationHistory: () => conversationHistory
|
|
345
|
+
});
|
|
346
|
+
const triggered = results.filter((result) => result.tripwireTriggered);
|
|
347
|
+
await onResults?.({ stage, text, results, triggered });
|
|
348
|
+
if (triggered.length > 0) {
|
|
349
|
+
throw new GuardrailTripwireError(stage, triggered);
|
|
350
|
+
}
|
|
351
|
+
return results;
|
|
352
|
+
}
|
|
353
|
+
const middleware = {
|
|
354
|
+
specificationVersion: "v4",
|
|
355
|
+
async transformParams({ params }) {
|
|
356
|
+
const text = extractPromptText(params.prompt, inputScope);
|
|
357
|
+
if (text.length === 0) {
|
|
358
|
+
return params;
|
|
359
|
+
}
|
|
360
|
+
const preflight = await runStage("pre_flight", text, params.prompt);
|
|
361
|
+
const mask = maskInput ? buildMask(preflight, text) : void 0;
|
|
362
|
+
const prompt = mask ? maskPrompt(params.prompt, mask) : params.prompt;
|
|
363
|
+
await runStage("input", mask ? extractPromptText(prompt, inputScope) : text, prompt);
|
|
364
|
+
return prompt === params.prompt ? params : { ...params, prompt };
|
|
365
|
+
},
|
|
366
|
+
async wrapGenerate({ doGenerate, params }) {
|
|
367
|
+
const result = await doGenerate();
|
|
368
|
+
await runStage("output", extractContentText(result.content), params.prompt);
|
|
369
|
+
return result;
|
|
370
|
+
},
|
|
371
|
+
async wrapStream({ doStream, params }) {
|
|
372
|
+
const result = await doStream();
|
|
373
|
+
if (streamMode === "off" || await runner.isEmpty("output")) {
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
const transform = createOutputCheckTransform({
|
|
377
|
+
mode: streamMode,
|
|
378
|
+
chunkChars,
|
|
379
|
+
check: async (text) => {
|
|
380
|
+
await runStage("output", text, params.prompt);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
return { ...result, stream: result.stream.pipeThrough(transform) };
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
return middleware;
|
|
387
|
+
}
|
|
388
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
389
|
+
0 && (module.exports = {
|
|
390
|
+
GuardrailCheckFailedError,
|
|
391
|
+
GuardrailTripwireError,
|
|
392
|
+
extractContentText,
|
|
393
|
+
extractPromptText,
|
|
394
|
+
guardrailsMiddleware,
|
|
395
|
+
toGuardrailConversation
|
|
396
|
+
});
|
|
397
|
+
//# sourceMappingURL=index.cjs.map
|