llm-output-guard 1.12.0 → 1.12.1

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,500 @@
1
+ # Provider adapters
2
+
3
+ One wrap per client. Every adapter drives the same `createStreamGuard`;
4
+ none of them reimplements it.
5
+
6
+ ### Vercel AI SDK
7
+
8
+ One wrap, and both `generateText` and `streamText` are guarded:
9
+
10
+ ```ts
11
+ import { wrapLanguageModel } from 'ai';
12
+ import { outputGuard } from 'llm-output-guard/ai-sdk';
13
+ import { presets } from 'llm-output-guard';
14
+
15
+ const model = wrapLanguageModel({
16
+ model: groq('llama-3.3-70b-versatile'),
17
+ middleware: outputGuard({ ...presets.chat, onDegenerate: 'throw' }),
18
+ });
19
+ ```
20
+
21
+ On `streamText` this cancels the provider's stream mid-generation. Driven
22
+ through the real SDK over a **mock part stream**, the source was pulled for **17
23
+ of 137 parts** before the guard cut it off. That figure is parts never requested
24
+ from a stub, not tokens never billed by a provider: the SDK's cancellation path
25
+ is exercised for real, the thing on the other end of it is not. On
26
+ `generateText` the tokens are already bought, so it throws
27
+ `DegenerateOutputError` instead, which your fallback layer can act on.
28
+
29
+ `onDegenerate` takes `'throw'` (default, also cancels the stream), `'abort'`
30
+ (stop cleanly, keep what arrived), or `'ignore'`. Start with `'ignore'` plus
31
+ `onVerdict` to watch your own traffic before letting a threshold fail anything:
32
+
33
+ ```ts
34
+ outputGuard({
35
+ ...presets.chat,
36
+ onDegenerate: 'ignore',
37
+ onVerdict: (verdict, { streaming }) => metrics.record(verdict.scores, { streaming }),
38
+ });
39
+ ```
40
+
41
+ `ai` is an **optional peer dependency** — importing the subpath does not pull it
42
+ in, and the main entry point has no peers at all. Supported: **`ai` v5, v6 and
43
+ v7**. CI installs the packed tarball against each of those and both typechecks
44
+ and runs the adapter, so the range is one that has been executed rather than
45
+ assumed.
46
+
47
+ **`ai` v4 is not supported, and forcing it will look like a bug in this
48
+ package.** v4's middleware hands back `text` where v5+ hands back a `content`
49
+ array, and streams `{ textDelta }` where v5+ streams `{ delta }`. This adapter
50
+ reads the v5+ shape, so on v4 it sees the empty string for every response —
51
+ which means **every healthy generation is flagged `EMPTY`, and under the default
52
+ `onDegenerate: 'throw'` every call throws `DegenerateOutputError`.** It is not
53
+ that the guard misses things on v4; it rejects everything. The peer range now
54
+ refuses the install so you find out at `npm install` rather than in production.
55
+ If you are pinned to v4, do not override it — stay on the core entry point and
56
+ call `checkOutput` on the result yourself.
57
+
58
+ ### OpenAI SDK — and anything speaking its protocol
59
+
60
+ One wrap, and both APIs are guarded — `chat.completions.create` and
61
+ `responses.create`, streaming and not:
62
+
63
+ ```ts
64
+ import OpenAI from 'openai';
65
+ import { withOutputGuard } from 'llm-output-guard/openai';
66
+ import { presets } from 'llm-output-guard';
67
+
68
+ const client = withOutputGuard(new OpenAI(), {
69
+ ...presets.chat,
70
+ onDegenerate: 'throw',
71
+ });
72
+
73
+ await client.chat.completions.create({ model, messages }); // guarded
74
+ await client.responses.create({ model, input }); // guarded
75
+ ```
76
+
77
+ ### `throw` stops a bad answer. `abort` saves tokens.
78
+
79
+ They are not two strengths of the same setting, and picking the wrong one is
80
+ the quiet failure this package exists to catch:
81
+
82
+ | | non-streaming call | stream |
83
+ |---|---|---|
84
+ | `'throw'` (default) | **fails the call** with `DegenerateOutputError` | fails it *and* cancels the request |
85
+ | `'abort'` | reports to `onVerdict`, **returns the response** | ends the stream, keeps what arrived |
86
+ | `'ignore'` | reports only | reports only |
87
+
88
+ **`'abort'` on a non-streaming call does not stop anything.** There is no stream
89
+ to end, so the guard measures the response, reports it, and hands it back — a
90
+ guard you believe in and do not have. It is the right choice on a stream where a
91
+ partial answer beats no answer, and only there.
92
+
93
+ `'throw'` is the default, so the shortest correct wrap is the one with no
94
+ `onDegenerate` at all.
95
+
96
+
97
+ The Responses API spells its stop reason `incomplete_details.reason` rather than
98
+ `finish_reason`, and its length stop `max_output_tokens` rather than `length`.
99
+ Both are mapped, so `TRUNCATED` fires the same way on either. `content_filter`
100
+ is deliberately *not* read as truncation — a filtered response is a different
101
+ failure, and reporting it as `TRUNCATED` would send a retry layer after the
102
+ wrong fix.
103
+
104
+ ### `responses.stream()` needed a different mechanism
105
+
106
+ It is guarded, and it was the last thing here that wasn't. A `ResponseStream` is
107
+ an `EventStream`, and it can be read **six** ways:
108
+
109
+ | path | detected | cancelled | throws |
110
+ |---|---|---|---|
111
+ | `for await (… of stream)` | yes | yes | yes |
112
+ | `await stream.finalResponse()` | yes | yes | yes |
113
+ | `await stream.done()` | yes | yes | yes |
114
+ | `stream.on('…', cb)` | yes | yes | — |
115
+ | `stream.once()` / `.emitted()` | yes | yes | — |
116
+ | `stream.events('…')` | yes | yes | — |
117
+
118
+ Replacing the iterator would have covered the first row and left five reading an
119
+ unchecked stream, which is why this shipped unguarded until 1.12.0 — half a
120
+ guard on the SDK's default streaming surface looks like coverage and is not.
121
+
122
+ What makes it work is that all six are fed by one event pump, so **one listener
123
+ sees every delta however you read it**, and `abort()` reaches the transport for
124
+ all of them. Throwing is the part that cannot be universal: three paths have an
125
+ error channel and three do not. A caller who only attached `on()` callbacks gets
126
+ the `onVerdict` report and a stream that stops — which is the half that saves
127
+ tokens — but no exception, because there is nowhere to put one.
128
+
129
+ `onDegenerate: 'abort'` ends the iterator cleanly and keeps what arrived.
130
+ `finalResponse()` and `done()` still reject under it, because there is no
131
+ partial value either can honestly return.
132
+
133
+ This is also how you guard **Groq, Together, OpenRouter, Fireworks, DeepInfra,
134
+ vLLM and Ollama** — anything you reach through an OpenAI-compatible `baseURL`
135
+ works, because the adapter is typed against the wire shapes rather than against
136
+ OpenAI the company.
137
+
138
+ Non-streaming calls are already paid for by the time anything can run, so a
139
+ degenerate one throws `DegenerateOutputError` for your fallback layer to catch:
140
+
141
+ ```ts
142
+ const completion = await client.chat.completions.create({ model, messages });
143
+ ```
144
+
145
+ Streaming is where it pays. The guard watches deltas and **cancels the HTTP
146
+ request** the moment a loop is detectable:
147
+
148
+ ```ts
149
+ const stream = await client.chat.completions.create({ model, messages, stream: true });
150
+ for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
151
+ ```
152
+
153
+ Driven through the real SDK against a looping model over a **mock transport**,
154
+ the response body was cancelled after **16 of 135 chunks** were generated — 88%
155
+ of the chunks were never produced.
156
+
157
+ **What that number is, precisely:** chunks a mock server was never asked to
158
+ produce after the client closed the connection, measured against an unguarded
159
+ baseline of the full 135. It is stronger than a "did abort fire" assertion —
160
+ the test observes cancellation at the response body, so a guard that stopped
161
+ iterating while the connection stayed open would fail it. It is **not** a
162
+ billing figure. A real provider sits behind buffering, its own chunking, and
163
+ server-side generation that may already have run ahead of what it has sent;
164
+ none of that exists in the mock. Treat 88% as evidence that cancellation
165
+ reaches the transport promptly, and measure your own provider before putting a
166
+ number in a budget.
167
+
168
+ `onDegenerate` and `onVerdict` are the same options as the Vercel adapter, from
169
+ the same type — `'throw'` (default, also cancels the stream), `'abort'` (stop
170
+ cleanly, keep what arrived), or `'ignore'`. Start with `'ignore'` plus
171
+ `onVerdict` to watch your own traffic first:
172
+
173
+ ```ts
174
+ withOutputGuard(new OpenAI(), {
175
+ ...presets.chat,
176
+ onDegenerate: 'ignore',
177
+ onVerdict: (verdict, { streaming }) =>
178
+ metrics.record(verdict.scores, { streaming, modes: verdict.modes }),
179
+ });
180
+ ```
181
+
182
+ `openai` is an **optional peer dependency**, and the wrapper is a proxy: every
183
+ other method on the client, and `create()`'s own `.withResponse()`, pass through
184
+ untouched. `finish_reason: 'length'` is mapped into the final check, so
185
+ `TRUNCATED` fires on a response that hit `max_tokens`.
186
+
187
+ **What runs when.** Mid-stream only the redundancy detectors are meaningful:
188
+ partial output is genuinely short, genuinely cut off, and genuinely not valid
189
+ JSON, so `TOO_SHORT`, `TRUNCATED`, `INVALID_JSON` and `LANG_MISMATCH` would
190
+ fire on every healthy generation and teach you to ignore the guard. They are
191
+ deferred to `end()`. `LOW_ENTROPY` is deferred too, for cost — it is ~100x the
192
+ other detectors, and everything it would have caught early is caught by
193
+ `REPETITION`, or by `TAIL_LOOP`'s character mode on non-spaced scripts.
194
+
195
+ Every adapter shares this behaviour because they all drive the same
196
+ `createStreamGuard`. None of them reimplements it.
197
+
198
+ ### Anthropic SDK
199
+
200
+ Same one wrap, same options:
201
+
202
+ ```ts
203
+ import Anthropic from '@anthropic-ai/sdk';
204
+ import { withOutputGuard } from 'llm-output-guard/anthropic';
205
+ import { presets } from 'llm-output-guard';
206
+
207
+ const client = withOutputGuard(new Anthropic(), {
208
+ ...presets.chat,
209
+ onDegenerate: 'throw',
210
+ });
211
+
212
+ await client.messages.create({ model, max_tokens, messages }); // guarded
213
+ await client.messages.create({ model, max_tokens, messages, stream: true }); // guarded
214
+ ```
215
+
216
+ Two things are specific to this API:
217
+
218
+ **Extended thinking is not read as the answer.** `thinking` blocks are the
219
+ model's reasoning, they are often longer than the answer, and they repeat
220
+ themselves as a matter of course while working a problem. Folding them into the
221
+ measured text would raise every repetition score on every thinking response and
222
+ flag the ones that thought hardest — so only `text` blocks are measured, and a
223
+ `thinking` block is not mistaken for a tool call either.
224
+
225
+ **Both of Anthropic's length stops map to `TRUNCATED`.** `max_tokens` passes
226
+ straight through; `model_context_window_exceeded` is the same event under a
227
+ different name and is normalised in the adapter. `refusal` is deliberately *not*
228
+ truncation — a refusal is a complete response that says no, which is a content
229
+ judgement this package does not make.
230
+
231
+ > **`messages.stream()` is not guarded**, for the same reason `responses.stream()`
232
+ > isn't: it returns a `MessageStream` — an event emitter with `.on()` and
233
+ > `.finalMessage()` — and guarding only its iteration would leave
234
+ > `.finalMessage()` unchecked. Use `create({ stream: true })`, or run
235
+ > `checkOutput` on `await stream.finalMessage()` yourself. `messages.batches` is
236
+ > unguarded too, and less interestingly: a batch is retrieved later as a file of
237
+ > results, so there is no response at `create` time to inspect.
238
+
239
+ `@anthropic-ai/sdk` is an **optional peer dependency**, declared
240
+ `>=0.60.0 <1.0.0` and verified at 0.60.0, 0.90.0 and 0.117.1 — each installing
241
+ the packed tarball and running the adapter for real, not just typechecking.
242
+
243
+ ### Google Gemini SDK
244
+
245
+ Same one wrap, same options — but Gemini splits streaming into its own method
246
+ rather than putting it behind a flag, so both are named:
247
+
248
+ ```ts
249
+ import { GoogleGenAI } from '@google/genai';
250
+ import { withOutputGuard } from 'llm-output-guard/google';
251
+ import { presets } from 'llm-output-guard';
252
+
253
+ const ai = withOutputGuard(new GoogleGenAI({ apiKey }), {
254
+ ...presets.chat,
255
+ onDegenerate: 'throw',
256
+ });
257
+
258
+ await ai.models.generateContent({ model, contents }); // guarded
259
+ await ai.models.generateContentStream({ model, contents }); // guarded
260
+ ```
261
+
262
+ Three things are specific to this API:
263
+
264
+ **Cancelling a stream works differently here, and it matters.**
265
+ `generateContentStream` resolves to a bare `AsyncGenerator`. Unlike the OpenAI
266
+ and Anthropic streams it carries no `AbortController`, so there is nothing on it
267
+ to abort — and ending our own iteration would leave the model generating, and
268
+ billing, while we looked away. The adapter therefore puts a signal *into* the
269
+ outgoing request as `config.abortSignal`, which is what reaches `fetch`. Your
270
+ request object is cloned rather than mutated, and a signal you supplied yourself
271
+ keeps working: ours follows yours rather than replacing it.
272
+
273
+ **Thought summaries are not read as the answer.** Gemini carries thinking in the
274
+ same `text` field as the answer, flagged `thought: true`, so that flag is the
275
+ only thing separating the two. Measured as answer text, reasoning would raise
276
+ every repetition score on every thinking-enabled response — the same reason
277
+ `./anthropic` skips `thinking` blocks.
278
+
279
+ **Only the first candidate is judged.** `candidateCount` above 1 asks for
280
+ *alternatives* to the same question and only one of them gets used.
281
+ Concatenating them would measure a document nobody receives, and measure it
282
+ wrongly in one direction: two good answers to one question share their subject,
283
+ vocabulary and often their structure, so the more consistent the model was the
284
+ higher its repetition score would climb.
285
+
286
+ `MAX_TOKENS` maps to `TRUNCATED` — it lands on the length stop the detector
287
+ already knows. Gemini's content stops (`SAFETY`, `RECITATION`, `BLOCKLIST`,
288
+ `PROHIBITED_CONTENT`, `SPII`) deliberately do **not**: each is a decision about
289
+ what the model may say rather than the model losing the thread, and reading one
290
+ as truncation would discard the response and spend a retry earning the identical
291
+ stop again.
292
+
293
+ > **`chats` is not guarded.** `chats.create()` returns a `Chat` that holds its
294
+ > own history, so guarding it means wrapping a value returned from a method
295
+ > rather than a response — and deciding what a degenerate turn does to history
296
+ > that has already been appended. Left plainly unguarded rather than half-done.
297
+ > Use `models.generateContent`, or run `checkOutput` on the result yourself.
298
+
299
+ `@google/genai` is an **optional peer dependency**, declared `>=1.0.0 <3.0.0`
300
+ and verified at 1.0.0, 1.9.0, 2.0.0 and 2.19.0 — each installing the packed
301
+ tarball and running the adapter for real, including the cancellation, not just
302
+ typechecking.
303
+
304
+ ### Tool calls and agents
305
+
306
+ A model that answers by calling a tool returns no assistant text — OpenAI sends
307
+ `content: null` beside `tool_calls`, and the AI SDK sends a `content` array with
308
+ no `text` part. Handed to `checkOutput`, that is an empty string, and an empty
309
+ string scores `EMPTY`.
310
+
311
+ So **the presence of tool calls means the text, if any, is a preamble rather
312
+ than the answer**, and every adapter judges it as one:
313
+
314
+ | | On a tool-calling turn |
315
+ |---|---|
316
+ | No text at all | Nothing is judged, and nothing is reported to `onVerdict` |
317
+ | Text beside the call | `REPETITION`, `TAIL_LOOP` and `LOW_ENTROPY` still run |
318
+ | `TOO_SHORT` | Off — "Let me look that up" is sixteen characters and correct |
319
+ | `TRUNCATED` | Off — a preamble ends without terminal punctuation as a matter of course |
320
+ | `INVALID_JSON` | Off — the JSON is in the call arguments, which your provider already validated against the schema |
321
+
322
+ The redundancy detectors stay on because a model looping in its preamble is
323
+ still a model that is looping. `EMPTY` is not disarmed either: a response with
324
+ neither text nor tool calls still fails, which is the case this package exists
325
+ for.
326
+
327
+ Nothing is reported to `onVerdict` for a text-free tool call on purpose. Those
328
+ samples are what a `calibrate` run is built from, and a spike of `EMPTY: 1` in
329
+ them would describe your agent's tool use rather than any degeneration.
330
+
331
+ ---
332
+
333
+ ---
334
+
335
+ [← Back to the README](../README.md) · [Try the playground](https://edwinsatya.github.io/llm-output-guard/)
336
+
337
+ ## Building an agent turn
338
+
339
+ Every subpath above also exports `toTurn`, which maps that provider's response
340
+ onto the shape `llm-output-guard/agent` reads:
341
+
342
+ ```ts
343
+ import { toTurn } from 'llm-output-guard/openai';
344
+ guard.observe(toTurn(completion));
345
+ ```
346
+
347
+ It carries the same provider knowledge the guard beside it does — thought parts
348
+ excluded on Gemini, thinking blocks on Anthropic, first choice or candidate
349
+ only. **[docs/agent-loops.md](agent-loops.md#building-a-turn-from-your-provider)**
350
+
351
+ ### What is covered
352
+
353
+ | Provider | Maps | Via |
354
+ |---|---|---|
355
+ | OpenAI — `chat.completions` and `responses` | fully | `./openai` |
356
+ | Anthropic | fully | `./anthropic` |
357
+ | Google Gemini | fully | `./google` |
358
+ | Vercel AI SDK | fully | `./ai-sdk` |
359
+ | Groq, Together, OpenRouter, Fireworks, DeepInfra, vLLM | fully | `./openai` |
360
+ | Ollama — OpenAI-compatible endpoint | fully | `./openai` |
361
+ | Azure OpenAI | fully | `./openai` — it *is* the `openai` SDK |
362
+ | Mistral SDK | fully | `./openai` — see below |
363
+ | AWS Bedrock, Cohere, LangChain, Ollama's native `/api/chat` | **nothing** | not adapted |
364
+
365
+ **Full, or nothing.** An unsupported envelope maps to an empty turn, which a
366
+ trace drops. There is deliberately no partial outcome, and that is the invariant
367
+ worth understanding: a turn with its text read and its calls missed
368
+ fingerprints by its *preamble prose* instead of its arguments, so an agent that
369
+ reuses one preamble while working through twenty files reads as a total
370
+ collapse. A false positive on a healthy run is the failure this package treats
371
+ as worse than a miss. The table is asserted in `test/openai-alikes.test.ts`.
372
+
373
+ **Mistral is why that invariant is written down.** Its generated SDK renames
374
+ `tool_calls` to `toolCalls`, and the rename broke more than the mapper: a
375
+ tool-calling turn carries no prose, so an unrecognised call list left
376
+ `content: ''` — which scores `EMPTY: 1`. Every tool call from a Mistral client
377
+ aborted under `withOutputGuard`. Both spellings are now read everywhere the list
378
+ is, which is safe rather than merely convenient: no response in the OpenAI
379
+ family carries both fields, so there is nothing to disambiguate.
380
+
381
+ ## Tool-call arguments
382
+
383
+ A tool-calling turn is judged by its preamble — the text beside the call — because
384
+ that text is not the answer. Which leaves the answer itself, the arguments,
385
+ unmeasured. Before 1.5 nothing here looked at them at all.
386
+
387
+ ```ts
388
+ const client = withOutputGuard(new OpenAI(), {
389
+ ...presets.chat,
390
+ checkToolArguments: true,
391
+ });
392
+ ```
393
+
394
+ Your provider already validates arguments against the schema you declared. That
395
+ covers **types**, not content — and a model that loops does not produce the wrong
396
+ type, it produces a valid string with nothing in it:
397
+
398
+ ```json
399
+ { "query": "site reliability engineering site reliability engineering …", "limit": 10 }
400
+ ```
401
+
402
+ Schema-valid. Passes the provider. Reaches your tool as a garbage search, or,
403
+ if the tool writes, persists the loop.
404
+
405
+ **What is measured.** Redundancy only, per string value — `REPETITION` and
406
+ `TAIL_LOOP`, under the same thresholds your guard already uses. The reason codes
407
+ are unchanged so existing handling keeps working; the `message` says the loop was
408
+ found in an argument rather than in the prose.
409
+
410
+ **What is not, and why.** `LOW_ENTROPY` is off because JSON is legitimately
411
+ repetitive at the character level, the same reason `presets.strictJson` turns it
412
+ off. `TRUNCATED`, `TOO_SHORT` and `INVALID_JSON` are off because the provider
413
+ already guarantees the arguments parse and match your schema — they would be
414
+ answering a question that has been answered. `SCRIPT_MISMATCH` and
415
+ `LANG_MISMATCH` are off because an argument is not prose addressed to a user: a
416
+ search query in another language is ordinary, not degenerate.
417
+
418
+ Values are measured **individually**, never as a serialised document. Two calls
419
+ against the same schema are legitimately near-identical documents, and an array
420
+ of repeated records inside one argument is the shape that was asked for — the
421
+ same rule `redundancyScope: 'jsonValues'` follows, for the same reason.
422
+
423
+ **Nothing is measured when there is nothing to measure.** A tool that takes no
424
+ parameters is called with `{}`, and arguments carrying no strings at all —
425
+ `{"lat":-6.2,"zoom":11}` — have no prose to judge. Both are skipped rather than
426
+ scored, so a no-argument tool does not fail on `EMPTY`.
427
+
428
+ **Non-streaming responses only.** Arguments arrive as JSON fragments that do not
429
+ parse until the call is complete, so there is nothing meaningful to measure
430
+ mid-stream.
431
+
432
+ Off by default. Switching it on can only make a response fail that previously
433
+ passed, so it is opt-in like every other behaviour change in this package.
434
+
435
+ ## Prompt echo
436
+
437
+ `PROMPT_ECHO` catches a model that replays your prompt instead of answering it.
438
+ It needs the prompt, and a guard is configured once when you wrap the client
439
+ while the prompt changes on every call — so this is a switch rather than a
440
+ value, and the adapter reads the prompt out of each request it is already
441
+ forwarding.
442
+
443
+ ```ts
444
+ const client = withOutputGuard(new OpenAI(), {
445
+ ...presets.chat,
446
+ checkPromptEcho: true,
447
+ });
448
+ ```
449
+
450
+ Works on all three adapters, and each reads its own request shape:
451
+ `messages` for `chat.completions`, `instructions` plus `input` for `responses`,
452
+ `system` plus `messages` for Anthropic, and the spec's normalised `params.prompt`
453
+ for the AI SDK. Content is read whether it is a plain string or a list of parts;
454
+ non-text parts (images, audio, files) contribute nothing, which is correct — they
455
+ are not text the model could echo back.
456
+
457
+ **Prior assistant turns are excluded.** The failure being measured is a model
458
+ replaying its *input*, and a model that keeps its terminology consistent across a
459
+ long conversation is doing its job. Counting its own earlier answers would make
460
+ that look worse the longer the conversation ran. Nothing is lost: a model that
461
+ loses the turn boundary and replays the whole transcript replays the system and
462
+ user text too.
463
+
464
+ **An explicit `prompt` in the options wins.** If you have a fixed system prompt
465
+ and prefer to state it once when wrapping the client, the adapter does not
466
+ override it.
467
+
468
+ **On a stream the check runs at the end**, not on the mid-stream windows,
469
+ because the score is a share of the whole output and a trailing window measures
470
+ the share of that window. See
471
+ [docs/detectors.md](./detectors.md#returning-the-prompt-instead-of-an-answer)
472
+ for the numbers.
473
+
474
+ > **Do not enable this on a rewrite, translate, summarise or extract endpoint.**
475
+ > Copying from the input is the job on those, so a correct answer scores high and
476
+ > the detector measures the task rather than a failure.
477
+
478
+ ## Aborting a wrong-language stream early
479
+
480
+ `SCRIPT_MISMATCH` and `PROMPT_ECHO` are judged at the end of a stream by
481
+ default, which means the whole response is already paid for by the time either
482
+ fires. `earlyDocumentChecks: true` lets them judge the buffer as it arrives:
483
+
484
+ ```ts
485
+ const client = withOutputGuard(new OpenAI(), {
486
+ ...presets.chat,
487
+ expectScript: 'latin',
488
+ earlyDocumentChecks: true,
489
+ onDegenerate: 'abort',
490
+ });
491
+ ```
492
+
493
+ Measured on a 3,000-character response answered entirely in the wrong script:
494
+ **7 of 30 chunks consumed instead of 30**, with the upstream request cancelled —
495
+ the same transport-level abort the redundancy detectors already trigger.
496
+
497
+ It is off by default and carries a measured false-positive risk, because the
498
+ buffer is a prefix and a prefix over-reports both detectors. Read
499
+ [docs/streaming.md](./streaming.md#judging-the-whole-response-early) before
500
+ switching it on — the floor, the bar, and the numbers behind both are there.