@groovysec/whiteout-ai 0.3.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.
@@ -0,0 +1,627 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /**
3
+ * App-key client for the Whiteout Guard API (`/v1/guard/*`).
4
+ *
5
+ * `AppGuard` covers a customer's own LLM application (a Custom AI App in the
6
+ * admin console). It authenticates with an app key (`WHITEOUT_APP_KEY`,
7
+ * `wo_app_<env>_...`), needs no registration, and blocks on both sides of the
8
+ * model call:
9
+ *
10
+ * const guard = new AppGuard(); // reads WHITEOUT_APP_KEY
11
+ * const client = guard.openai(new OpenAI()); // same client, governed
12
+ * await client.chat.completions.create({ model: "gpt-4o", messages });
13
+ *
14
+ * Fail behaviour: when Whiteout can't be reached, the policy group's
15
+ * `fail_behavior` (from `/v1/guard/config`) decides unless `failOpen` is set.
16
+ * With no config yet the default is fail-open.
17
+ *
18
+ * Streaming: `streamMode: "buffered"` (default) holds the whole response until
19
+ * the output check passes. `"passthrough"` yields chunks as they arrive and
20
+ * checks every `checkEvery` characters plus once at the end; text already
21
+ * shown can't be retracted, so use buffered mode where you enforce.
22
+ *
23
+ * The enrollment-token `WhiteoutGuard` (`/infra-agent/*`) is unchanged.
24
+ */
25
+ import { WhiteoutBlockedError, WhiteoutConfigError } from "./exceptions.js";
26
+ export const DEFAULT_BASE_URL = "https://api.whiteout.groovysec.com";
27
+ export const SCAN_TARGETS = ["user", "system", "context", "tool"];
28
+ const MAX_TEXT = 200_000;
29
+ const MAX_MESSAGES = 500;
30
+ const MAX_EVENTS_PER_BATCH = 100;
31
+ const CONFIG_TTL_MS = 300_000;
32
+ const CONFIG_RETRY_MS = 60_000;
33
+ const ROLE_ALIASES = { developer: "system", function: "tool", human: "user", ai: "assistant" };
34
+ const ROLES = new Set(["system", "user", "assistant", "tool"]);
35
+ export const SDK_VERSION = "0.3.0";
36
+ /** What a quarantined system / tool turn is replaced with before the model sees it (ids kept, so tool calls stay paired). */
37
+ export const QUARANTINE_NOTICE = "[Removed by Whiteout: this content contained instructions aimed at the AI assistant and was not passed to the model.]";
38
+ /** `docs` minus the ones Whiteout quarantined — pass the same list you sent as `context`. */
39
+ export function cleanContext(docs, decision) {
40
+ const drop = new Set(decision.quarantine?.context ?? []);
41
+ return docs.filter((_, i) => !drop.has(i));
42
+ }
43
+ // ── helpers ─────────────────────────────────────────────────────────────────
44
+ const get = (obj, key) => obj !== null && typeof obj === "object" ? obj[key] : undefined;
45
+ function blockText(content) {
46
+ if (content == null)
47
+ return "";
48
+ if (typeof content === "string")
49
+ return content;
50
+ if (Array.isArray(content)) {
51
+ return content
52
+ .map((b) => {
53
+ if (typeof b === "string")
54
+ return b;
55
+ const t = get(b, "type");
56
+ if (["text", "input_text", "output_text"].includes(t) || (t === undefined && typeof get(b, "text") === "string"))
57
+ return get(b, "text") ?? "";
58
+ return "";
59
+ })
60
+ .filter(Boolean)
61
+ .join("\n");
62
+ }
63
+ return String(content);
64
+ }
65
+ /** Chat history from OpenAI, Anthropic or LangChain shapes → Guard API messages. */
66
+ export function normalizeMessages(messages, system) {
67
+ return normalizeWithOrigin(messages, system).map((e) => e.msg);
68
+ }
69
+ function normalizeWithOrigin(messages, system) {
70
+ let out = [];
71
+ const sys = blockText(system);
72
+ if (sys)
73
+ out.push({ msg: { role: "system", content: sys.slice(0, MAX_TEXT) }, origin: { i: "system" } });
74
+ (messages ?? []).forEach((m, i) => {
75
+ // LangChain JS messages expose their kind via _getType() ("human", "ai", ...).
76
+ const lcType = typeof get(m, "_getType") === "function" ? m._getType() : undefined;
77
+ let role = get(m, "role") ?? lcType ?? get(m, "type");
78
+ role = ROLE_ALIASES[role] ?? role;
79
+ if (!ROLES.has(role))
80
+ return;
81
+ const content = get(m, "content");
82
+ const text = blockText(content);
83
+ if (text)
84
+ out.push({ msg: { role, content: text.slice(0, MAX_TEXT) }, origin: { i } });
85
+ if (Array.isArray(content)) {
86
+ content.forEach((b, bi) => {
87
+ if (get(b, "type") !== "tool_result")
88
+ return;
89
+ const t = blockText(get(b, "content"));
90
+ if (t)
91
+ out.push({ msg: { role: "tool", content: t.slice(0, MAX_TEXT) }, origin: { i, b: bi } });
92
+ });
93
+ }
94
+ });
95
+ if (out.length > MAX_MESSAGES) {
96
+ const systems = out.filter((e) => e.msg.role === "system").slice(0, MAX_MESSAGES);
97
+ const keep = MAX_MESSAGES - systems.length;
98
+ const rest = out.filter((e) => e.msg.role !== "system");
99
+ out = systems.concat(keep ? rest.slice(-keep) : []);
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * A copy of the provider params with every quarantined system / tool turn replaced by
105
+ * QUARANTINE_NOTICE. Tool results keep their ids; the caller's objects are never mutated.
106
+ */
107
+ export function applyQuarantine(params, decision, systemKey = "system") {
108
+ const idx = decision.quarantine?.messages ?? [];
109
+ if (!idx.length)
110
+ return params;
111
+ const origins = normalizeWithOrigin(params.messages, systemKey ? params[systemKey] : undefined).map((e) => e.origin);
112
+ const messages = [...(params.messages ?? [])];
113
+ const out = { ...params };
114
+ for (const n of idx) {
115
+ const o = origins[n];
116
+ if (!o)
117
+ continue;
118
+ if (o.i === "system") {
119
+ if (systemKey)
120
+ out[systemKey] = QUARANTINE_NOTICE;
121
+ continue;
122
+ }
123
+ const m = messages[o.i];
124
+ if (o.b === undefined)
125
+ messages[o.i] = { ...m, content: QUARANTINE_NOTICE };
126
+ else {
127
+ const blocks = [...(m.content ?? [])];
128
+ blocks[o.b] = { ...blocks[o.b], content: QUARANTINE_NOTICE };
129
+ messages[o.i] = { ...m, content: blocks };
130
+ }
131
+ }
132
+ out.messages = messages;
133
+ return out;
134
+ }
135
+ function normalizeContext(context) {
136
+ if (!context?.length)
137
+ return undefined;
138
+ return context.slice(0, 100).map((d) => {
139
+ if (typeof d === "string")
140
+ return { content: d.slice(0, MAX_TEXT) };
141
+ const doc = { content: String(d.content ?? get(d, "pageContent") ?? "").slice(0, MAX_TEXT) };
142
+ const source = d.source ?? get(get(d, "metadata"), "source");
143
+ if (source)
144
+ doc.source = String(source).slice(0, 300);
145
+ return doc;
146
+ });
147
+ }
148
+ function toDecision(data) {
149
+ const reason = String(data.reason ?? "");
150
+ const decision = (data.decision ?? "allow");
151
+ return {
152
+ decision,
153
+ violatedPolicies: Array.isArray(data.violated_policies) ? data.violated_policies : [],
154
+ reason,
155
+ evaluationId: data.evaluation_id ?? undefined,
156
+ failOpen: Boolean(data.fail_open),
157
+ mode: String(data.mode ?? ""),
158
+ latencyMs: data.latency_ms,
159
+ rule: data.rule,
160
+ responseFlagged: Boolean(data.response_flagged),
161
+ failedClosed: reason.endsWith("(fail-closed)"),
162
+ allowed: decision !== "block",
163
+ ...(data.user ? { user: data.user } : {}),
164
+ ...(data.identity ? { identity: data.identity } : {}),
165
+ ...(data.injection ? { injection: data.injection } : {}),
166
+ quarantine: {
167
+ messages: Array.isArray(data.quarantine?.messages) ? data.quarantine.messages : [],
168
+ context: Array.isArray(data.quarantine?.context) ? data.quarantine.context : [],
169
+ },
170
+ };
171
+ }
172
+ /**
173
+ * True while a governed model call is in flight — auto-instrumentation passes such
174
+ * calls straight through, so a hand-wrapped client is never checked twice.
175
+ * @internal
176
+ */
177
+ export const guarding = new AsyncLocalStorage();
178
+ const randomId = () => globalThis.crypto?.randomUUID?.().replace(/-/g, "") ?? `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`;
179
+ export const OPENAI = {
180
+ systemKey: null,
181
+ name: "openai",
182
+ createPath: ["chat", "completions", "create"],
183
+ text: (r) => blockText(get(get(get(r, "choices")?.[0], "message"), "content")),
184
+ usage: (r) => [get(get(r, "usage"), "prompt_tokens"), get(get(r, "usage"), "completion_tokens")],
185
+ chunk: (c) => {
186
+ const text = get(get(get(c, "choices")?.[0], "delta"), "content") ?? "";
187
+ return [typeof text === "string" ? text : "", get(get(c, "usage"), "prompt_tokens"), get(get(c, "usage"), "completion_tokens")];
188
+ },
189
+ };
190
+ /** OpenAI Responses API: `input` (string or items) + `instructions`. */
191
+ export const OPENAI_RESPONSES = {
192
+ name: "openai",
193
+ createPath: ["responses", "create"],
194
+ text: (r) => {
195
+ const t = get(r, "output_text");
196
+ if (typeof t === "string")
197
+ return t;
198
+ return (get(r, "output") ?? [])
199
+ .flatMap((item) => get(item, "content") ?? [])
200
+ .filter((c) => get(c, "type") === "output_text")
201
+ .map((c) => get(c, "text") ?? "")
202
+ .join("");
203
+ },
204
+ usage: (r) => [get(get(r, "usage"), "input_tokens"), get(get(r, "usage"), "output_tokens")],
205
+ chunk: (e) => {
206
+ const type = get(e, "type");
207
+ if (type === "response.output_text.delta")
208
+ return [get(e, "delta") ?? "", undefined, undefined];
209
+ if (type === "response.completed") {
210
+ const u = get(get(e, "response"), "usage");
211
+ return ["", get(u, "input_tokens"), get(u, "output_tokens")];
212
+ }
213
+ return ["", undefined, undefined];
214
+ },
215
+ messagesOf: (p) => {
216
+ const input = p.input;
217
+ if (typeof input === "string")
218
+ return [[{ role: "user", content: input }], p.instructions];
219
+ const msgs = (Array.isArray(input) ? input : []).flatMap((item) => get(item, "type") === "function_call_output" ? [{ role: "tool", content: String(get(item, "output") ?? "") }]
220
+ : get(item, "role") ? [{ role: get(item, "role"), content: get(item, "content") }] : []);
221
+ return [msgs, p.instructions];
222
+ },
223
+ quarantine: false,
224
+ };
225
+ export const ANTHROPIC = {
226
+ systemKey: "system",
227
+ name: "anthropic",
228
+ createPath: ["messages", "create"],
229
+ text: (r) => blockText(get(r, "content")),
230
+ usage: (r) => [get(get(r, "usage"), "input_tokens"), get(get(r, "usage"), "output_tokens")],
231
+ chunk: (e) => {
232
+ const type = get(e, "type");
233
+ if (type === "content_block_delta") {
234
+ const d = get(e, "delta");
235
+ return [["text_delta", undefined].includes(get(d, "type")) ? get(d, "text") ?? "" : "", undefined, undefined];
236
+ }
237
+ if (type === "message_start")
238
+ return ["", get(get(get(e, "message"), "usage"), "input_tokens"), undefined];
239
+ if (type === "message_delta")
240
+ return ["", undefined, get(get(e, "usage"), "output_tokens")];
241
+ return ["", undefined, undefined];
242
+ },
243
+ };
244
+ /** Proxy that swaps in `replacement` at a dotted path and delegates everything else. */
245
+ function patchPath(target, path, replacement) {
246
+ const [head, ...rest] = path;
247
+ return new Proxy(target, {
248
+ // Read through the real object (not the proxy) so #private getters keep working.
249
+ get(obj, prop) {
250
+ if (prop === head) {
251
+ if (!rest.length)
252
+ return replacement;
253
+ return patchPath(Reflect.get(obj, prop), rest, replacement);
254
+ }
255
+ const value = Reflect.get(obj, prop);
256
+ return typeof value === "function" ? value.bind(obj) : value;
257
+ },
258
+ });
259
+ }
260
+ // ── client ──────────────────────────────────────────────────────────────────
261
+ export class AppGuard {
262
+ key;
263
+ baseUrl;
264
+ timeoutMs;
265
+ failOpen;
266
+ blockOutput;
267
+ scan;
268
+ streamMode;
269
+ checkEvery;
270
+ fetchFn;
271
+ log;
272
+ cfg = null;
273
+ cfgEtag = null;
274
+ cfgCheckedAt = null;
275
+ cfgInflight = null;
276
+ pending = new Set();
277
+ enforce;
278
+ auto;
279
+ appRef;
280
+ constructor(opts = {}) {
281
+ const env = typeof process !== "undefined" ? process.env : {};
282
+ const key = opts.appKey ?? env.WHITEOUT_APP_KEY ?? env.WHITEOUT_GATEWAY_KEY ?? "";
283
+ if (!key)
284
+ throw new WhiteoutConfigError("appKey is required (or set WHITEOUT_APP_KEY)");
285
+ if (!key.startsWith("wo_app_") && !key.startsWith("wo_gw_"))
286
+ throw new WhiteoutConfigError("appKey must be a Whiteout app key (wo_app_...) or gateway key (wo_gw_...). Enrollment tokens belong to WhiteoutGuard.");
287
+ this.scan = AppGuard.checkScan(opts.scan ?? ["user"]);
288
+ const streamMode = opts.streamMode ?? "buffered";
289
+ if (streamMode !== "buffered" && streamMode !== "passthrough")
290
+ throw new WhiteoutConfigError('streamMode must be "buffered" or "passthrough"');
291
+ const checkEvery = opts.checkEvery ?? 2000;
292
+ if (!(checkEvery >= 1))
293
+ throw new WhiteoutConfigError("checkEvery must be positive");
294
+ const fetchFn = opts.fetch ?? globalThis.fetch;
295
+ if (!fetchFn)
296
+ throw new WhiteoutConfigError("global fetch is unavailable; pass options.fetch (Node 18+ has it)");
297
+ this.key = key;
298
+ this.baseUrl = (opts.baseUrl ?? env.WHITEOUT_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
299
+ this.timeoutMs = opts.timeoutMs ?? 10_000;
300
+ this.failOpen = opts.failOpen;
301
+ this.blockOutput = opts.blockOutput ?? true;
302
+ this.streamMode = streamMode;
303
+ this.checkEvery = checkEvery;
304
+ this.fetchFn = fetchFn;
305
+ this.log = opts.logger ?? console;
306
+ this.enforce = opts.enforce ?? true;
307
+ this.auto = opts.auto ?? false;
308
+ this.appRef = opts.appRef ?? env.WHITEOUT_APP_REF ?? undefined;
309
+ }
310
+ static checkScan(scan) {
311
+ if (!scan.length || scan.some((s) => !SCAN_TARGETS.includes(s)))
312
+ throw new WhiteoutConfigError(`scan must be a non-empty subset of ${SCAN_TARGETS.join(", ")}`);
313
+ return [...scan];
314
+ }
315
+ async request(method, path, body, headers = {}) {
316
+ try {
317
+ return await this.fetchFn(this.baseUrl + path, {
318
+ method,
319
+ headers: {
320
+ Authorization: `Bearer ${this.key}`,
321
+ "User-Agent": `whiteout-ai-node/${SDK_VERSION}${this.auto ? " (auto)" : ""}`,
322
+ ...(this.appRef ? { "X-Whiteout-App": this.appRef } : {}),
323
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
324
+ ...headers,
325
+ },
326
+ body: body !== undefined ? JSON.stringify(body) : undefined,
327
+ signal: AbortSignal.timeout(this.timeoutMs),
328
+ });
329
+ }
330
+ catch (e) {
331
+ return e instanceof Error ? e : new Error(String(e));
332
+ }
333
+ }
334
+ configStale() {
335
+ return this.cfgCheckedAt === null || Date.now() - this.cfgCheckedAt >= CONFIG_TTL_MS;
336
+ }
337
+ effectiveFailOpen() {
338
+ return this.failOpen ?? this.cfg?.fail_behavior !== "closed";
339
+ }
340
+ /** The app's policy-group settings (mode, fail behaviour, ...). Cached with ETag. */
341
+ async config(refresh = false) {
342
+ if (!refresh && !this.configStale())
343
+ return this.cfg;
344
+ if (this.cfgInflight)
345
+ return this.cfgInflight;
346
+ this.cfgInflight = (async () => {
347
+ const resp = await this.request("GET", "/v1/guard/config", undefined, this.cfgEtag ? { "If-None-Match": this.cfgEtag } : {});
348
+ const now = Date.now();
349
+ this.cfgCheckedAt = now;
350
+ if (resp instanceof Error || (resp.status !== 200 && resp.status !== 304)) {
351
+ this.cfgCheckedAt = now - (CONFIG_TTL_MS - CONFIG_RETRY_MS);
352
+ if (!(resp instanceof Error) && (resp.status === 401 || resp.status === 403))
353
+ this.log.error(`[Whiteout] app key rejected by /v1/guard/config (${resp.status})`);
354
+ }
355
+ else if (resp.status === 200) {
356
+ try {
357
+ this.cfg = (await resp.json());
358
+ this.cfgEtag = resp.headers.get("etag");
359
+ }
360
+ catch {
361
+ /* keep the previous config */
362
+ }
363
+ }
364
+ return this.cfg;
365
+ })().finally(() => {
366
+ this.cfgInflight = null;
367
+ });
368
+ return this.cfgInflight;
369
+ }
370
+ fail(what, evaluationId) {
371
+ const fo = this.effectiveFailOpen();
372
+ this.log.warn(`[Whiteout] ${what} — ${fo ? "failing open" : "failing closed"}`);
373
+ return {
374
+ decision: fo ? "allow" : "block",
375
+ violatedPolicies: [],
376
+ reason: `${what} (${fo ? "fail-open" : "fail-closed"})`,
377
+ evaluationId,
378
+ failOpen: fo,
379
+ mode: "",
380
+ responseFlagged: false,
381
+ failedClosed: !fo,
382
+ allowed: fo,
383
+ quarantine: { messages: [], context: [] },
384
+ };
385
+ }
386
+ async interpret(resp, what, evaluationId) {
387
+ if (resp instanceof Error)
388
+ return this.fail(`${what}: Whiteout unreachable (${resp.name})`, evaluationId);
389
+ if (resp.status === 200) {
390
+ try {
391
+ return toDecision((await resp.json()));
392
+ }
393
+ catch {
394
+ return this.fail(`${what}: unreadable response`, evaluationId);
395
+ }
396
+ }
397
+ let code = "";
398
+ try {
399
+ const detail = (await resp.json())?.detail;
400
+ code = typeof detail === "object" && detail ? String(detail.code ?? "") : "";
401
+ }
402
+ catch {
403
+ /* no body */
404
+ }
405
+ if (resp.status === 401 || resp.status === 403)
406
+ this.log.error(`[Whiteout] app key rejected (${resp.status} ${code || "unauthorized"}) — coverage is NOT being applied`);
407
+ return this.fail(`${what}: HTTP ${resp.status}${code ? " " + code : ""}`, evaluationId);
408
+ }
409
+ raiseIfBlocked(d, stage) {
410
+ if (d.decision === "block" && this.enforce)
411
+ throw new WhiteoutBlockedError({
412
+ reason: d.reason,
413
+ violatedPolicies: d.violatedPolicies,
414
+ rule: d.rule == null ? "" : String(d.rule),
415
+ stage,
416
+ evaluationId: d.evaluationId,
417
+ failedClosed: d.failedClosed,
418
+ });
419
+ return d;
420
+ }
421
+ /** Evaluate a prompt before it reaches the model. Throws WhiteoutBlockedError on block. */
422
+ async checkInput(opts) {
423
+ if (this.failOpen === undefined && this.configStale())
424
+ await this.config();
425
+ const messages = opts.messages !== undefined || opts.system ? normalizeMessages(opts.messages, opts.system) : undefined;
426
+ const body = { scan: opts.scan ? AppGuard.checkScan(opts.scan) : this.scan };
427
+ if (messages?.length)
428
+ body.messages = messages;
429
+ if (opts.text !== undefined)
430
+ body.text = String(opts.text).slice(0, MAX_TEXT);
431
+ else if (!messages?.length)
432
+ body.text = "";
433
+ const extra = {
434
+ context: normalizeContext(opts.context),
435
+ end_user: opts.endUser,
436
+ session_id: opts.sessionId,
437
+ model: opts.model,
438
+ provider: opts.provider,
439
+ token_count_in: opts.tokenCountIn,
440
+ metadata: opts.metadata,
441
+ };
442
+ for (const [k, v] of Object.entries(extra))
443
+ if (v !== undefined && v !== null)
444
+ body[k] = v;
445
+ const userHeaders = {
446
+ ...(opts.userToken ? { "X-Whiteout-User-Token": String(opts.userToken) } : {}),
447
+ ...(opts.appRef ? { "X-Whiteout-App": String(opts.appRef) } : {}),
448
+ };
449
+ const d = await this.interpret(await this.request("POST", "/v1/guard/input", body, userHeaders), "input check");
450
+ return opts.raiseOnBlock === false ? d : this.raiseIfBlocked(d, "input");
451
+ }
452
+ /** Evaluate a model response. `finish: false` marks a streaming checkpoint. */
453
+ async checkOutput(evaluationId, text, opts = {}) {
454
+ const body = {
455
+ evaluation_id: evaluationId || randomId(),
456
+ text: (text ?? "").slice(0, MAX_TEXT),
457
+ finish: opts.finish ?? true,
458
+ };
459
+ const extra = {
460
+ model: opts.model,
461
+ provider: opts.provider,
462
+ token_count_in: opts.tokenCountIn,
463
+ token_count_out: opts.tokenCountOut,
464
+ };
465
+ for (const [k, v] of Object.entries(extra))
466
+ if (v !== undefined && v !== null)
467
+ body[k] = v;
468
+ const outHeaders = opts.appRef ? { "X-Whiteout-App": String(opts.appRef) } : {};
469
+ const d = await this.interpret(await this.request("POST", "/v1/guard/output", body, outHeaders), "output check", body.evaluation_id);
470
+ return opts.raiseOnBlock === false ? d : this.raiseIfBlocked(d, "output");
471
+ }
472
+ /** @internal Blocking or fire-and-forget output check, per `blockOutput`. */
473
+ async afterOutput(evaluationId, text, opts) {
474
+ if (this.blockOutput) {
475
+ await this.checkOutput(evaluationId, text, opts);
476
+ return;
477
+ }
478
+ const p = this.checkOutput(evaluationId, text, { ...opts, raiseOnBlock: false }).catch(() => undefined);
479
+ this.pending.add(p);
480
+ void p.finally(() => this.pending.delete(p));
481
+ }
482
+ /** Report calls after the fact (observe-only). Batched 100 per request. */
483
+ async sendEvents(events) {
484
+ let accepted = 0;
485
+ let flagged = 0;
486
+ for (let i = 0; i < events.length; i += MAX_EVENTS_PER_BATCH) {
487
+ const resp = await this.request("POST", "/v1/guard/events", { events: events.slice(i, i + MAX_EVENTS_PER_BATCH) });
488
+ if (resp instanceof Error || resp.status !== 202) {
489
+ this.log.warn(`[Whiteout] event batch not accepted (${resp instanceof Error ? resp.name : resp.status})`);
490
+ continue;
491
+ }
492
+ const data = (await resp.json());
493
+ accepted += data.accepted ?? 0;
494
+ flagged += data.flagged ?? 0;
495
+ }
496
+ return { accepted, flagged };
497
+ }
498
+ /** Wait for fire-and-forget output checks (call before a serverless handler returns). */
499
+ async flush() {
500
+ await Promise.all([...this.pending]);
501
+ }
502
+ /** Govern `client.chat.completions.create` and `client.responses.create` on an `openai` client. */
503
+ openai(client) {
504
+ const wrapped = this.wrapClient(client, OPENAI);
505
+ return client.responses?.create ? this.wrapClient(wrapped, OPENAI_RESPONSES) : wrapped;
506
+ }
507
+ /** Govern `client.messages.create` on an `@anthropic-ai/sdk` client. */
508
+ anthropic(client) {
509
+ return this.wrapClient(client, ANTHROPIC);
510
+ }
511
+ /**
512
+ * @internal One governed model call: input check, quarantine, the call, output check.
513
+ * `call` sends the (possibly rewritten) params to the provider.
514
+ */
515
+ async guardedCreate(provider, call, params) {
516
+ const { whiteout, ...providerParams } = params ?? {};
517
+ const opts = (whiteout ?? {});
518
+ const model = providerParams.model;
519
+ const [messages, system] = provider.messagesOf
520
+ ? provider.messagesOf(providerParams)
521
+ : [providerParams.messages, providerParams.system];
522
+ const d = await this.checkInput({
523
+ messages,
524
+ system,
525
+ model,
526
+ provider: provider.name,
527
+ endUser: opts.endUser,
528
+ userToken: opts.userToken,
529
+ appRef: opts.appRef,
530
+ sessionId: opts.sessionId,
531
+ scan: opts.scan,
532
+ context: opts.context,
533
+ metadata: opts.metadata,
534
+ });
535
+ const sent = provider.quarantine !== false && d.quarantine.messages.length
536
+ ? applyQuarantine(providerParams, d, provider.systemKey ?? null)
537
+ : providerParams;
538
+ const resp = await guarding.run(true, () => call(sent));
539
+ if (providerParams.stream)
540
+ return guardStream(this, resp, d.evaluationId, provider, model);
541
+ const [tokenCountIn, tokenCountOut] = provider.usage(resp);
542
+ await this.afterOutput(d.evaluationId, provider.text(resp), { model, provider: provider.name, tokenCountIn, tokenCountOut });
543
+ return resp;
544
+ }
545
+ wrapClient(client, provider) {
546
+ const parent = provider.createPath.slice(0, -1).reduce((o, k) => o?.[k], client);
547
+ const method = provider.createPath[provider.createPath.length - 1];
548
+ if (typeof parent?.[method] !== "function")
549
+ throw new WhiteoutConfigError(`client has no ${provider.createPath.join(".")}() — is this a ${provider.name} client?`);
550
+ const create = (params, ...rest) => this.guardedCreate(provider, (p) => parent[method](p, ...rest), params);
551
+ return patchPath(client, provider.createPath, create);
552
+ }
553
+ }
554
+ /** Wrap a provider stream so iterating it runs the output checks. Other properties delegate. */
555
+ function guardStream(guard, stream, evaluationId, provider, model) {
556
+ const parts = [];
557
+ let length = 0;
558
+ let checkedAt = 0;
559
+ let tin;
560
+ let tout;
561
+ const feed = (chunk) => {
562
+ const [text, i, o] = provider.chunk(chunk);
563
+ if (i !== undefined)
564
+ tin = i;
565
+ if (o !== undefined)
566
+ tout = o;
567
+ if (text) {
568
+ parts.push(text);
569
+ length += text.length;
570
+ }
571
+ return length - checkedAt >= guard.checkEvery;
572
+ };
573
+ const opts = (finish) => {
574
+ checkedAt = length;
575
+ return finish
576
+ ? { finish, model, provider: provider.name, tokenCountIn: tin, tokenCountOut: tout }
577
+ : { finish, model, provider: provider.name };
578
+ };
579
+ const source = stream;
580
+ const abort = () => {
581
+ const ctl = get(stream, "controller");
582
+ if (ctl && typeof ctl.abort === "function")
583
+ ctl.abort();
584
+ };
585
+ async function* iterate() {
586
+ if (!guard.blockOutput) {
587
+ for await (const chunk of source) {
588
+ feed(chunk);
589
+ yield chunk;
590
+ }
591
+ await guard.afterOutput(evaluationId, parts.join(""), opts(true));
592
+ return;
593
+ }
594
+ if (guard.streamMode === "buffered") {
595
+ const chunks = [];
596
+ for await (const chunk of source) {
597
+ feed(chunk);
598
+ chunks.push(chunk);
599
+ }
600
+ await guard.checkOutput(evaluationId, parts.join(""), opts(true));
601
+ yield* chunks;
602
+ return;
603
+ }
604
+ for await (const chunk of source) {
605
+ if (feed(chunk)) {
606
+ try {
607
+ await guard.checkOutput(evaluationId, parts.join(""), opts(false));
608
+ }
609
+ catch (e) {
610
+ abort();
611
+ throw e;
612
+ }
613
+ }
614
+ yield chunk;
615
+ }
616
+ await guard.checkOutput(evaluationId, parts.join(""), opts(true));
617
+ }
618
+ return new Proxy(stream, {
619
+ get(obj, prop) {
620
+ if (prop === Symbol.asyncIterator)
621
+ return () => iterate();
622
+ const value = Reflect.get(obj, prop);
623
+ return typeof value === "function" ? value.bind(obj) : value;
624
+ },
625
+ });
626
+ }
627
+ //# sourceMappingURL=appGuard.js.map