llm-output-guard 1.2.1 → 1.3.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.
package/README.md CHANGED
@@ -1,73 +1,89 @@
1
1
  # llm-output-guard
2
2
 
3
- Detect LLM responses that failed **while returning `200 OK`**.
3
+ **Detect LLM responses that failed while returning `200 OK`.**
4
4
 
5
- Zero runtime dependencies. Deterministic. Composes with whatever retry or fallback layer you already have.
5
+ [![npm](https://img.shields.io/npm/v/llm-output-guard?color=0b7285)](https://www.npmjs.com/package/llm-output-guard)
6
+ [![minzipped](https://img.shields.io/bundlephobia/minzip/llm-output-guard?color=0b7285&label=min%2Bgzip)](https://bundlephobia.com/package/llm-output-guard)
7
+ [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://github.com/edwinsatya/llm-output-guard/blob/main/package.json)
8
+ [![CI](https://github.com/edwinsatya/llm-output-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/edwinsatya/llm-output-guard/actions/workflows/ci.yml)
9
+ [![license](https://img.shields.io/npm/l/llm-output-guard?color=0b7285)](./LICENSE)
10
+
11
+ Your retry layer watches for `429`, `5xx` and timeouts. It cannot see a model that
12
+ looped until `max_tokens`, returned `{}`, stopped mid-sentence, or answered in the
13
+ wrong language — because all of those arrive as a **successful request**.
14
+
15
+ This produces the signal that layer is missing. Zero dependencies, ~3 KB gzipped,
16
+ synchronous, no network.
6
17
 
7
18
  ```bash
8
19
  npm i llm-output-guard
9
20
  ```
10
21
 
11
- **[Try it in your browser →](https://edwinsatya.github.io/llm-output-guard/)** — every detector, running on
12
- this repo's own fixtures or on your own pasted output. No API key and no request:
13
- the library is zero-dependency and synchronous, so the page runs the real thing.
14
-
15
- ---
16
-
17
- ## Why this exists
18
-
19
- A model in my interview-question pipeline started returning garbage — the same clause repeated until it hit the token ceiling. Every layer reported success:
22
+ ```ts
23
+ import { checkOutput, presets } from 'llm-output-guard';
20
24
 
21
- - the provider returned `200`
22
- - the SDK parsed the envelope without complaint
23
- - the response had non-zero length
25
+ const verdict = checkOutput(await callModel(prompt), presets.chat);
24
26
 
25
- So the retry policy saw nothing worth acting on. The bad response was cached, served, and counted as a success. What surfaced instead was a *latency* problem, because downstream code kept retrying around a response that was technically fine.
27
+ if (!verdict.ok) {
28
+ console.warn(verdict.reasons); // [{ code: 'TAIL_LOOP', score: 0.9, ... }]
29
+ // fall through to your next provider
30
+ }
31
+ ```
26
32
 
27
- Retry and fallback libraries key off **transport** signals: `429`, `5xx`, timeouts. None of them look at whether the content means anything. That is the gap this package fills.
33
+ **[Try it in your browser →](https://edwinsatya.github.io/llm-output-guard/)**
34
+ every detector, running on your own pasted output. No API key, no request.
28
35
 
29
- ## What it is not
36
+ > **It is not a hallucination detector.** It measures *shape*, never truth. It
37
+ > cannot tell you the model was wrong; it can tell you the model stopped
38
+ > producing language.
30
39
 
31
- This is **not** another fallback chain. Those exist and they are good:
40
+ ---
32
41
 
33
- - [`cockatiel`](https://github.com/connor4312/cockatiel) retry, circuit breaker, timeout, bulkhead
34
- - [`ai-fallback`](https://www.npmjs.com/package/ai-fallback) — model fallback for the Vercel AI SDK
42
+ ## What it catches
35
43
 
36
- `llm-output-guard` produces the *signal* those layers are missing. Use them together.
44
+ | Code | Catches | Signal |
45
+ |---|---|---|
46
+ | `EMPTY` | Whitespace, lone punctuation, `{}`, empty fences | Content presence |
47
+ | `TOO_SHORT` | Non-empty but useless | Length vs. minimum |
48
+ | `REPETITION` | Loops and stutters | Duplicate word n-gram fraction |
49
+ | `TAIL_LOOP` | Good start, then a stuck ending | Periodicity in the trailing window |
50
+ | `LOW_ENTROPY` | Character-level collapse, token artifacts | Compression ratio |
51
+ | `TRUNCATED` | Cut off mid-thought | `finish_reason`, unbalanced fences |
52
+ | `INVALID_JSON` | Prose around the payload, wrong types | Parse + key + schema contract |
53
+ | `LANG_MISMATCH` | Answered in the wrong language | Function-word profile (opt-in) |
37
54
 
38
- ---
55
+ Every detector runs even after one fails, so a verdict shows the whole picture
56
+ rather than whichever check happened to be ordered first. Each returns **0–1, not
57
+ a boolean** — you pick the line. Full reference: **[docs/detectors.md](docs/detectors.md)**.
39
58
 
40
- ## Usage
59
+ ## Guard your provider in one wrap
41
60
 
42
61
  ```ts
43
- import { checkOutput, presets } from 'llm-output-guard';
44
-
45
- const text = await callModel(prompt);
46
- const verdict = checkOutput(text, presets.chat);
62
+ import OpenAI from 'openai';
63
+ import { withOutputGuard } from 'llm-output-guard/openai';
47
64
 
48
- if (!verdict.ok) {
49
- console.warn('degenerate output', verdict.reasons);
50
- // fall through to your next provider
51
- }
65
+ const client = withOutputGuard(new OpenAI(), { ...presets.chat, onDegenerate: 'abort' });
52
66
  ```
53
67
 
54
- ### Throwing form, for existing retry layers
68
+ Adapters for the **OpenAI SDK** (both `chat.completions` and `responses`),
69
+ **Anthropic**, and the **Vercel AI SDK** — plus anything speaking OpenAI's
70
+ protocol: Groq, Together, OpenRouter, Fireworks, vLLM, Ollama.
55
71
 
56
- ```ts
57
- import { assertOutput, DegenerateOutputError, presets } from 'llm-output-guard';
58
- import { retry, handleWhen, ExponentialBackoff } from 'cockatiel';
72
+ On a stream this **cancels the HTTP request** the moment a loop is detectable, so
73
+ you stop paying for the rest of it. See **[docs/adapters.md](docs/adapters.md)**
74
+ and **[docs/streaming.md](docs/streaming.md)**.
59
75
 
60
- const policy = retry(
61
- handleWhen((err) => err instanceof DegenerateOutputError || isTransport(err)),
62
- { maxAttempts: 3, backoff: new ExponentialBackoff() },
63
- );
76
+ ## The hard part is not catching loops
64
77
 
65
- const text = await policy.execute(async () =>
66
- assertOutput(await callModel(prompt), presets.chat),
67
- );
68
- ```
78
+ A miss is annoying. **A false positive is worse** — a healthy response gets
79
+ discarded and retried against a slower provider for nothing.
69
80
 
70
- `DegenerateOutputError` carries `.retryable === true` and the full `.verdict`.
81
+ So the corpus carries deliberate traps: markdown tables, repeated-prefix lists,
82
+ code blocks, rhetorical refrains, a Chinese poem refrain. All repetitive, all
83
+ fine, all flagged by a naive detector. Paste one into the
84
+ [playground](https://edwinsatya.github.io/llm-output-guard/) and watch it pass.
85
+
86
+ ---
71
87
 
72
88
  ### Structured output
73
89
 
@@ -121,313 +137,9 @@ as a missing key rather than as whatever the schema calls it.
121
137
  > This is the one thing in the package that throws about your configuration; it
122
138
  > still never throws about a response.
123
139
 
124
- ### Streaming, where it stops costing you tokens
125
-
126
- Checking a finished response tells you that you already paid for it. A model
127
- that starts looping keeps looping until `max_tokens`, and you are billed for
128
- every one of those tokens and made to wait for them.
129
-
130
- `guardStream` watches the response as it arrives and tells you the moment it
131
- goes wrong, so you can abort the generation instead of buying the rest of it:
132
-
133
- ```ts
134
- import { guardStream, presets } from 'llm-output-guard';
135
-
136
- const controller = new AbortController();
137
- const stream = await callModel(prompt, { signal: controller.signal });
138
-
139
- for await (const chunk of guardStream(stream, {
140
- ...presets.chat,
141
- onDegenerate: (verdict) => {
142
- console.warn('model started looping', verdict.reasons);
143
- controller.abort();
144
- },
145
- })) {
146
- process.stdout.write(chunk);
147
- }
148
- ```
149
-
150
- Against the degenerate fixtures, the guard reports a failure after **8-52%** of
151
- each fixture's characters:
152
-
153
- ```
154
- repetition-word-stutter caught at 240/2999 chars -> 92% not yet read
155
- repetition-clause-loop caught at 240/1680 chars -> 86% not yet read
156
- tail-loop-after-good-start caught at 640/1569 chars -> 59% not yet read
157
- tail-loop-trailing-phrase caught at 640/1238 chars -> 48% not yet read
158
- ```
159
-
160
- **Read that as detection latency, not as a saving.** It is measured by feeding
161
- fixture strings to `createStreamGuard` in-process — there is no provider and no
162
- connection involved, so it says how early the signal is available and nothing
163
- about tokens or cost. What you do with the signal is the part that saves money,
164
- and how much it saves depends on your provider.
165
-
166
- Zero of the healthy fixtures trip it, and the watching costs **~0.05ms per
167
- check** — around 0.7ms across a 5,500 character response, flat as the stream
168
- grows rather than quadratic in its length.
169
-
170
- **Those numbers are measured on Latin-script fixtures and do not carry over
171
- unchanged.** For Chinese, Japanese and Thai the same in-process measurement
172
- gives **0-85%**, and the spread is the whole story:
173
-
174
- ```
175
- cjk-tail-loop-th-nopunct caught at 240/1640 chars -> 85% not yet read
176
- cjk-tail-loop-ja-nopunct caught at 240/800 chars -> 70% not yet read
177
- cjk-tail-loop-zh-nopunct caught at 240/640 chars -> 63% not yet read
178
- cjk-tail-loop-diluted caught at 1840/2303 chars -> 20% not yet read
179
- cjk-tail-loop-short never mid-stream (128 chars, under the warmup)
180
- ```
181
-
182
- A response that loops from the start saves what a Latin one saves. A response
183
- that answers properly and *then* falls into a Chinese loop is caught late,
184
- because there is nothing to detect until the loop begins — 20% on that fixture,
185
- and less on a longer healthy prefix. Responses shorter than the 240-character
186
- warmup are never judged mid-stream at all; they are caught by `end()`, after you
187
- have paid for them.
188
-
189
- Late detection is still worth having: it stops a broken response being cached,
190
- returned, or counted as a success, which is the reason this package exists. It
191
- is just not the token saving, and you should not budget for one.
192
-
193
- For manual control over the loop, use the primitive:
194
-
195
- ```ts
196
- const guard = createStreamGuard(presets.chat);
197
-
198
- for await (const chunk of stream) {
199
- const verdict = guard.push(chunk); // null until a check actually runs
200
- if (verdict && !verdict.ok) break;
201
- yield chunk;
202
- }
203
-
204
- const final = guard.end(finishReason); // full check, all detectors
205
- ```
206
-
207
- ### Vercel AI SDK
208
-
209
- One wrap, and both `generateText` and `streamText` are guarded:
210
-
211
- ```ts
212
- import { wrapLanguageModel } from 'ai';
213
- import { outputGuard } from 'llm-output-guard/ai-sdk';
214
- import { presets } from 'llm-output-guard';
215
-
216
- const model = wrapLanguageModel({
217
- model: groq('llama-3.3-70b-versatile'),
218
- middleware: outputGuard({ ...presets.chat, onDegenerate: 'abort' }),
219
- });
220
- ```
221
-
222
- On `streamText` this cancels the provider's stream mid-generation. Driven
223
- through the real SDK over a **mock part stream**, the source was pulled for **17
224
- of 137 parts** before the guard cut it off. That figure is parts never requested
225
- from a stub, not tokens never billed by a provider: the SDK's cancellation path
226
- is exercised for real, the thing on the other end of it is not. On
227
- `generateText` the tokens are already bought, so it throws
228
- `DegenerateOutputError` instead, which your fallback layer can act on.
229
-
230
- `onDegenerate` takes `'throw'` (default, also cancels the stream), `'abort'`
231
- (stop cleanly, keep what arrived), or `'ignore'`. Start with `'ignore'` plus
232
- `onVerdict` to watch your own traffic before letting a threshold fail anything:
233
-
234
- ```ts
235
- outputGuard({
236
- ...presets.chat,
237
- onDegenerate: 'ignore',
238
- onVerdict: (verdict, { streaming }) => metrics.record(verdict.scores, { streaming }),
239
- });
240
- ```
241
-
242
- `ai` is an **optional peer dependency** — importing the subpath does not pull it
243
- in, and the main entry point has no peers at all. Supported: **`ai` v5, v6 and
244
- v7**. CI installs the packed tarball against each of those and both typechecks
245
- and runs the adapter, so the range is one that has been executed rather than
246
- assumed.
247
-
248
- **`ai` v4 is not supported, and forcing it will look like a bug in this
249
- package.** v4's middleware hands back `text` where v5+ hands back a `content`
250
- array, and streams `{ textDelta }` where v5+ streams `{ delta }`. This adapter
251
- reads the v5+ shape, so on v4 it sees the empty string for every response —
252
- which means **every healthy generation is flagged `EMPTY`, and under the default
253
- `onDegenerate: 'throw'` every call throws `DegenerateOutputError`.** It is not
254
- that the guard misses things on v4; it rejects everything. The peer range now
255
- refuses the install so you find out at `npm install` rather than in production.
256
- If you are pinned to v4, do not override it — stay on the core entry point and
257
- call `checkOutput` on the result yourself.
258
-
259
- ### OpenAI SDK — and anything speaking its protocol
260
-
261
- One wrap, and both APIs are guarded — `chat.completions.create` and
262
- `responses.create`, streaming and not:
263
-
264
- ```ts
265
- import OpenAI from 'openai';
266
- import { withOutputGuard } from 'llm-output-guard/openai';
267
- import { presets } from 'llm-output-guard';
268
-
269
- const client = withOutputGuard(new OpenAI(), {
270
- ...presets.chat,
271
- onDegenerate: 'abort',
272
- });
273
-
274
- await client.chat.completions.create({ model, messages }); // guarded
275
- await client.responses.create({ model, input }); // guarded
276
- ```
277
-
278
- The Responses API spells its stop reason `incomplete_details.reason` rather than
279
- `finish_reason`, and its length stop `max_output_tokens` rather than `length`.
280
- Both are mapped, so `TRUNCATED` fires the same way on either. `content_filter`
281
- is deliberately *not* read as truncation — a filtered response is a different
282
- failure, and reporting it as `TRUNCATED` would send a retry layer after the
283
- wrong fix.
284
-
285
- > **`responses.stream()` is not guarded.** It returns a `ResponseStream` — an
286
- > event emitter with `.on()` and `.finalResponse()`, not just an async iterable
287
- > — and wrapping only its iteration would guard a `for await` consumer while
288
- > leaving `.finalResponse()` unchecked. A guard you believe in and do not have
289
- > is the failure this package was written about, so it is left plainly
290
- > unguarded rather than half-wrapped. Use `create({ stream: true })`, which is
291
- > guarded, or run `checkOutput` on `await stream.finalResponse()` yourself.
292
-
293
- This is also how you guard **Groq, Together, OpenRouter, Fireworks, DeepInfra,
294
- vLLM and Ollama** — anything you reach through an OpenAI-compatible `baseURL`
295
- works, because the adapter is typed against the wire shapes rather than against
296
- OpenAI the company.
297
-
298
- Non-streaming calls are already paid for by the time anything can run, so a
299
- degenerate one throws `DegenerateOutputError` for your fallback layer to catch:
300
-
301
- ```ts
302
- const completion = await client.chat.completions.create({ model, messages });
303
- ```
304
-
305
- Streaming is where it pays. The guard watches deltas and **cancels the HTTP
306
- request** the moment a loop is detectable:
307
-
308
- ```ts
309
- const stream = await client.chat.completions.create({ model, messages, stream: true });
310
- for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
311
- ```
312
-
313
- Driven through the real SDK against a looping model over a **mock transport**,
314
- the response body was cancelled after **16 of 135 chunks** were generated — 88%
315
- of the chunks were never produced.
316
-
317
- **What that number is, precisely:** chunks a mock server was never asked to
318
- produce after the client closed the connection, measured against an unguarded
319
- baseline of the full 135. It is stronger than a "did abort fire" assertion —
320
- the test observes cancellation at the response body, so a guard that stopped
321
- iterating while the connection stayed open would fail it. It is **not** a
322
- billing figure. A real provider sits behind buffering, its own chunking, and
323
- server-side generation that may already have run ahead of what it has sent;
324
- none of that exists in the mock. Treat 88% as evidence that cancellation
325
- reaches the transport promptly, and measure your own provider before putting a
326
- number in a budget.
327
-
328
- `onDegenerate` and `onVerdict` are the same options as the Vercel adapter, from
329
- the same type — `'throw'` (default, also cancels the stream), `'abort'` (stop
330
- cleanly, keep what arrived), or `'ignore'`. Start with `'ignore'` plus
331
- `onVerdict` to watch your own traffic first:
332
-
333
- ```ts
334
- withOutputGuard(new OpenAI(), {
335
- ...presets.chat,
336
- onDegenerate: 'ignore',
337
- onVerdict: (verdict, { streaming }) =>
338
- metrics.record(verdict.scores, { streaming, modes: verdict.modes }),
339
- });
340
- ```
341
-
342
- `openai` is an **optional peer dependency**, and the wrapper is a proxy: every
343
- other method on the client, and `create()`'s own `.withResponse()`, pass through
344
- untouched. `finish_reason: 'length'` is mapped into the final check, so
345
- `TRUNCATED` fires on a response that hit `max_tokens`.
346
-
347
- **What runs when.** Mid-stream only the redundancy detectors are meaningful:
348
- partial output is genuinely short, genuinely cut off, and genuinely not valid
349
- JSON, so `TOO_SHORT`, `TRUNCATED`, `INVALID_JSON` and `LANG_MISMATCH` would
350
- fire on every healthy generation and teach you to ignore the guard. They are
351
- deferred to `end()`. `LOW_ENTROPY` is deferred too, for cost — it is ~100x the
352
- other detectors, and everything it would have caught early is caught by
353
- `REPETITION`, or by `TAIL_LOOP`'s character mode on non-spaced scripts.
354
-
355
- Every adapter shares this behaviour because they all drive the same
356
- `createStreamGuard`. None of them reimplements it.
357
-
358
- ### Anthropic SDK
359
-
360
- Same one wrap, same options:
361
-
362
- ```ts
363
- import Anthropic from '@anthropic-ai/sdk';
364
- import { withOutputGuard } from 'llm-output-guard/anthropic';
365
- import { presets } from 'llm-output-guard';
366
-
367
- const client = withOutputGuard(new Anthropic(), {
368
- ...presets.chat,
369
- onDegenerate: 'abort',
370
- });
371
-
372
- await client.messages.create({ model, max_tokens, messages }); // guarded
373
- await client.messages.create({ model, max_tokens, messages, stream: true }); // guarded
374
- ```
375
-
376
- Two things are specific to this API:
377
-
378
- **Extended thinking is not read as the answer.** `thinking` blocks are the
379
- model's reasoning, they are often longer than the answer, and they repeat
380
- themselves as a matter of course while working a problem. Folding them into the
381
- measured text would raise every repetition score on every thinking response and
382
- flag the ones that thought hardest — so only `text` blocks are measured, and a
383
- `thinking` block is not mistaken for a tool call either.
384
-
385
- **Both of Anthropic's length stops map to `TRUNCATED`.** `max_tokens` passes
386
- straight through; `model_context_window_exceeded` is the same event under a
387
- different name and is normalised in the adapter. `refusal` is deliberately *not*
388
- truncation — a refusal is a complete response that says no, which is a content
389
- judgement this package does not make.
390
-
391
- > **`messages.stream()` is not guarded**, for the same reason `responses.stream()`
392
- > isn't: it returns a `MessageStream` — an event emitter with `.on()` and
393
- > `.finalMessage()` — and guarding only its iteration would leave
394
- > `.finalMessage()` unchecked. Use `create({ stream: true })`, or run
395
- > `checkOutput` on `await stream.finalMessage()` yourself. `messages.batches` is
396
- > unguarded too, and less interestingly: a batch is retrieved later as a file of
397
- > results, so there is no response at `create` time to inspect.
398
-
399
- `@anthropic-ai/sdk` is an **optional peer dependency**, declared
400
- `>=0.60.0 <1.0.0` and verified at 0.60.0, 0.90.0 and 0.117.1 — each installing
401
- the packed tarball and running the adapter for real, not just typechecking.
402
-
403
- ### Tool calls and agents
404
-
405
- A model that answers by calling a tool returns no assistant text — OpenAI sends
406
- `content: null` beside `tool_calls`, and the AI SDK sends a `content` array with
407
- no `text` part. Handed to `checkOutput`, that is an empty string, and an empty
408
- string scores `EMPTY`.
409
-
410
- So **the presence of tool calls means the text, if any, is a preamble rather
411
- than the answer**, and both adapters judge it as one:
412
-
413
- | | On a tool-calling turn |
414
- |---|---|
415
- | No text at all | Nothing is judged, and nothing is reported to `onVerdict` |
416
- | Text beside the call | `REPETITION`, `TAIL_LOOP` and `LOW_ENTROPY` still run |
417
- | `TOO_SHORT` | Off — "Let me look that up" is sixteen characters and correct |
418
- | `TRUNCATED` | Off — a preamble ends without terminal punctuation as a matter of course |
419
- | `INVALID_JSON` | Off — the JSON is in the call arguments, which your provider already validated against the schema |
420
-
421
- The redundancy detectors stay on because a model looping in its preamble is
422
- still a model that is looping. `EMPTY` is not disarmed either: a response with
423
- neither text nor tool calls still fails, which is the case this package exists
424
- for.
425
-
426
- Nothing is reported to `onVerdict` for a text-free tool call on purpose. Those
427
- samples are what a `calibrate` run is built from, and a spike of `EMPTY: 1` in
428
- them would describe your agent's tool use rather than any degeneration.
429
-
430
- ---
140
+ See **[docs/detectors.md](docs/detectors.md)** for arrays of repeated records
141
+ a JSON array of identical rows reads as a loop under the default scope, and
142
+ `redundancyScope: 'jsonValues'` is the fix.
431
143
 
432
144
  ## The verdict
433
145
 
@@ -452,37 +164,6 @@ scripts and characters on Chinese, Japanese and Thai; those are two
452
164
  distributions with different base rates, and aggregating them into one histogram
453
165
  gives you a number that describes neither.
454
166
 
455
- ## Detectors
456
-
457
- | Code | Catches | Signal | Exported as |
458
- |---|---|---|---|
459
- | `EMPTY` | Whitespace, lone punctuation, `{}`, empty fences | Content presence | `emptinessScore` |
460
- | `TOO_SHORT` | Non-empty but useless | Length vs. minimum | `shortnessScore` |
461
- | `REPETITION` | Loops and stutters | Duplicate word n-gram fraction | `repetitionScore` |
462
- | `TAIL_LOOP` | Good start, then a stuck ending | Periodicity in the trailing window, over words or characters | `tailLoopScore`, `tailLoopDetail` |
463
- | `LOW_ENTROPY` | Character-level collapse, token artifacts | Hand-rolled LZ77 compression ratio | `compressibilityScore`, `compressionRatio` |
464
- | `TRUNCATED` | Cut off mid-thought | `finish_reason`, unbalanced fences/brackets | `truncationScore` |
465
- | `INVALID_JSON` | Prose around the payload, missing keys, wrong types | Parse + key contract + optional schema | `jsonScore`, `stripFence` |
466
- | `LANG_MISMATCH` | Answered in the wrong language | Function-word profile (coarse, opt-in) | `languageMismatchScore`, `languageProfile`, `supportedLanguages` |
467
-
468
- Every detector is exported on its own if you only want one, and every name in
469
- that last column is covered by semver — see **Stability**.
470
-
471
- ```ts
472
- import { repetitionScore, tailLoopDetail, stripFence } from 'llm-output-guard';
473
-
474
- repetitionScore(text); // 0..1, higher is worse
475
- repetitionScore(text, { n: 4 }); // n-gram size
476
- tailLoopDetail(text, { mode: 'char' }); // { score, mode } — which tokenizer ran
477
- stripFence('```json\n{"a":1}\n```'); // '{"a":1}'
478
- ```
479
-
480
- Each takes `(text, options?)` and returns a `0..1` score, with three exceptions
481
- worth knowing: `shortnessScore(text, minChars)` takes its minimum positionally,
482
- `stripFence` returns a string, and `jsonScore` / `tailLoopDetail` return a detail
483
- object rather than a bare number. `supportedLanguages` is a value, not a
484
- function — the array `['id', 'en', 'es']`.
485
-
486
167
  ## Presets
487
168
 
488
169
  `chat` · `strictJson` · `longForm` · `lenient`
@@ -493,125 +174,25 @@ They are starting points calibrated against the fixture corpus in this repo —
493
174
 
494
175
  ## Calibrating against your own traffic
495
176
 
496
- The shipped presets are tuned on the fixture corpus, which is not your traffic.
497
- Log your scores for a week, then let the CLI read them back:
177
+ The shipped presets are tuned on this repo's fixture corpus, which is **not your
178
+ traffic**. Log your scores for a week, then derive thresholds you can defend:
498
179
 
499
180
  ```bash
500
- npx llm-output-guard calibrate scores.jsonl
501
- # or: cat scores.jsonl | npx llm-output-guard calibrate --fpr 0.001
502
- ```
503
-
181
+ npx llm-output-guard calibrate scores.jsonl --fpr 0.001
504
182
  ```
505
- 8,000 verdicts — flagging budget 0.10% of traffic
506
- ! sample is too small for a 0.10% rate: it rests on the top ~8 scores, and
507
- ~10,000 verdicts are needed before that tail means anything
508
-
509
- REPETITION n=7,993
510
- p50 0.000 p90 0.000 p99 0.100 p99.9 0.944 max 0.991
511
- gap 0.114 -> 0.705 (15 above, 0.19% of traffic)
512
- suggest maxRepetition: 0.409
513
-
514
- TAIL_LOOP n=7,993
515
- p50 0.000 p90 0.000 p99 0.000 p99.9 0.000 max 0.789
516
- gap 0.000 -> 0.789 (1 above, 0.01% of traffic)
517
- suggest maxTailLoop: 0.394
518
- ! the separation rests on 1 sample; treat it as a lead to confirm, not a
519
- calibrated threshold
520
- ```
521
-
522
- Input is JSONL and the parsing is deliberately forgiving — a bare scores
523
- object, a whole `Verdict`, or either of those buried in a wider log record all
524
- work, because a calibration step you have to reshape your logs for is one you
525
- will not run. `--json` emits the same analysis as data.
526
-
527
- If you log `modes` alongside `scores`, detectors are segmented by tokenizer and
528
- reported as `TAIL_LOOP [word]` and `TAIL_LOOP [char]`, each suggesting its own
529
- option. Do this if your traffic is not all one script: pooled, the two
530
- distributions produce a single threshold that is wrong for both — word-mode
531
- `TAIL_LOOP` on Indonesian traffic has a healthy maximum near 0.35 where character
532
- mode's is near 0.06.
533
-
534
- **What it can and cannot tell you.** The corpus can compute a real margin
535
- because every fixture is labelled. Your logs are not, and no arithmetic
536
- recovers a label that was never written down. So these numbers bound *false
537
- positives* — how much of your own traffic a threshold would flag — on the
538
- assumption that degeneration is rare in it. They say nothing about what a
539
- threshold catches; a detector that never fires has a perfect false-positive
540
- rate. The `gap` line is the exception worth trusting, because a hole between
541
- the bulk and a cluster of outliers is real separation observed in your data
542
- rather than an assumption about rarity — and when that hole rests on one or
543
- two samples, the report says so.
544
-
545
- ### The same thing, as a function
546
-
547
- The CLI is a wrapper. If your scores already live somewhere the shell cannot
548
- reach them — a metrics store, a warehouse query, a test — call `calibrate`
549
- directly. It takes the same flat objects the JSONL format describes:
550
-
551
- ```ts
552
- import { calibrate } from 'llm-output-guard';
553
-
554
- const { n, summaries } = calibrate(
555
- [
556
- { REPETITION: 0.03, TAIL_LOOP: 0 },
557
- { REPETITION: 0.91, TAIL_LOOP: 0.88, modes: { TAIL_LOOP: 'char' } },
558
- // ...one entry per logged verdict
559
- ],
560
- { falsePositiveRate: 0.001 },
561
- );
562
-
563
- for (const s of summaries) {
564
- s.code; // 'REPETITION'
565
- s.mode; // 'word' | 'char', when the samples recorded one
566
- s.suggested; // threshold flagging falsePositiveRate of this sample
567
- s.gap; // { below, above, count, share } | null — stronger evidence
568
- s.distribution; // { n, nonZero, min, max, p50, p90, p99, p999 }
569
- s.caveats; // everything that makes `suggested` untrustworthy
570
- }
571
- ```
572
-
573
- `modes` rides along in the same object and is not read as a score. Log it, and
574
- `summaries` comes back segmented — one entry per `code`+`mode` — for the reason
575
- in the paragraph above. `summarise(code, scores, options)` is exported too, for
576
- when you have one detector's numbers already grouped.
577
-
578
- **Read `caveats` before `suggested`.** It is where a sample too small for the
579
- requested rate says so, and a `suggested` number carries no warning of its own.
580
-
581
- ## On thresholds
582
183
 
583
- A miss is annoying. **A false positive is worse**: a healthy response gets discarded and retried against a slower provider for nothing.
184
+ The report tells you when your sample is too small to support the rate you asked
185
+ for, and distinguishes real separation in your data from a false-positive budget —
186
+ because only one of those is evidence. Full guide:
187
+ **[docs/calibration.md](docs/calibration.md)**.
584
188
 
585
- So the corpus carries deliberate traps — markdown tables, repeated-prefix lists, code blocks, rhetorical refrains — all of which a naive detector flags. `npm run calibrate` prints the margin between the worst healthy score and the weakest degenerate one:
586
-
587
- ```
588
- === TAIL_LOOP [word] ===
589
- healthy max : 0.000 (code-block-typescript)
590
- degenerate min: 0.900 (tail-loop-after-good-start)
591
- margin : 0.900 OK
592
-
593
- === TAIL_LOOP [char] ===
594
- healthy max : 0.291 (prose-zh-poem-refrain)
595
- degenerate min: 0.829 (cjk-refrain-x20)
596
- margin : 0.538 OK
597
- ```
598
-
599
- Detectors with two tokenizers are reported per mode, and each detector is scored
600
- only against fixtures labelled for it — otherwise a tail loop that `LOW_ENTROPY`
601
- was never meant to catch drags `LOW_ENTROPY`'s margin negative and the report
602
- reads like a regression in something nobody touched.
603
-
604
- If that margin ever goes thin, the answer is a better detector, not a nudged
605
- threshold. That rule is why `REPETITION` has no character mode: the one that was
606
- built came out with a *negative* margin, so it was deleted rather than tuned.
607
-
608
- ## Growing the corpus
609
-
610
- ```bash
611
- GROQ_API_KEY=… node scripts/generate-fixtures.mjs --model llama-3.1-8b-instant --n 8
612
- ```
189
+ ## Script coverage
613
190
 
614
- Output lands in `test/fixtures/raw/` **unreviewed**. Read each one, label it, then move it into `bad/` or `good/`. Nothing is auto-promoted: a fixture you have not read is a threshold you cannot defend.
191
+ Korean, Cyrillic, Greek, Arabic and Devanagari separate words and are handled like
192
+ English. **Chinese, Japanese and Thai do not**, so `TAIL_LOOP` switches to
193
+ character mode and reads its own threshold. `REPETITION` is blind on those scripts —
194
+ a known, measured gap, with the numbers behind it in
195
+ **[docs/script-coverage.md](docs/script-coverage.md)**.
615
196
 
616
197
  ## Design notes
617
198
 
@@ -621,97 +202,6 @@ Output lands in `test/fixtures/raw/` **unreviewed**. Read each one, label it, th
621
202
  - **Scores, not booleans.** Detectors report 0–1 and leave the threshold decision to you.
622
203
  - **Abstains rather than guesses.** Samples too short to judge score 0.
623
204
 
624
- ## Script coverage
625
-
626
- The dividing line is **whether a script puts spaces between words**, not whether
627
- it is Latin. Korean, Cyrillic, Greek, Arabic and Devanagari all separate words
628
- and are handled exactly like English. Han, Hiragana, Katakana and Thai do not,
629
- and get different treatment:
630
-
631
- | | Chinese / Japanese / Thai | Everything else |
632
- |---|---|---|
633
- | `TAIL_LOOP` | **Character mode**, `maxCharTailLoop` (default 0.7) | Word mode, `maxTailLoop` (default 0.5) |
634
- | `REPETITION` | **Blind — see below** | Word n-grams, works |
635
- | `LOW_ENTROPY`, `TRUNCATED`, `INVALID_JSON`, `EMPTY`, `TOO_SHORT` | Character- or structure-based, unaffected | Same |
636
- | `LANG_MISMATCH` | Not covered (`id`/`en`/`es` only) | `id`/`en`/`es` only |
637
-
638
- Mode is chosen per detector, from the span that detector actually reads — so a
639
- reply that answers in English and then loops in Chinese puts the *tail* detector
640
- into character mode without moving anything else. It is reported in
641
- `Verdict.modes`.
642
-
643
- **`REPETITION` is blind on these scripts, and we could not fix it.** A word
644
- tokenizer sees a punctuation-delimited Chinese clause as one token, and a loop
645
- with no punctuation as one token for the entire response, so it scores 0.000 on
646
- an obvious loop. A character n-gram fallback was built, measured, and rejected —
647
- because **it would add no coverage and cost a false-positive surface**.
648
-
649
- It adds nothing because `TAIL_LOOP`'s character mode already catches every
650
- degenerate non-Latin sample in the corpus, at a margin of 0.538.
651
-
652
- It costs something because healthy *structured* CJK output scores high under it.
653
- Repeated key scaffolding around short CJK values is genuinely redundant
654
- character-by-character:
655
-
656
- ```
657
- healthy json-zh-keys-valid, char n-grams (n=4), all items distinct
658
- 8 items 0.396 20 items 0.543 40 items 0.597
659
- 12 items 0.474 30 items 0.577
660
- ```
661
-
662
- That flattens rather than diverging — it converges on the scaffolding's own
663
- proportion — so a threshold does exist. But the plateau near 0.6 against the
664
- weakest pure loop at 0.872 leaves about **0.19**, under the 0.2 margin this
665
- package holds itself to, and the healthy side climbs with the number of keys a
666
- payload carries. A detector with nothing to add and a structure-sensitive margin
667
- is a false positive waiting for someone's payload shape to change, which is the
668
- wrong trade here.
669
-
670
- `TAIL_LOOP`'s character mode covers the gap in practice — it requires *exact*
671
- periodicity, which scaffolding never produces, and it catches every degenerate
672
- non-Latin sample in the corpus. But a mid-response CJK loop that recovers before
673
- the end is not detected by anything here. If that is your failure mode, log
674
- `LOW_ENTROPY` and threshold it yourself.
675
-
676
- Two more things worth knowing:
677
-
678
- - **Character mode abstains below 80 characters.** Three identical short
679
- sentences closing a 40-character reply look like total coverage and are not
680
- evidence of anything.
681
-
682
- ### Character mode is deliberately slower to fire
683
-
684
- The two modes do not flag the same shape at the same point, and the gap is
685
- large. Taking the clearest case — a response ending in an identical repeated
686
- line — measured on both:
687
-
688
- | Repeats of an identical closing line | English (`maxTailLoop` 0.5) | Chinese (`maxCharTailLoop` 0.7) |
689
- |---|---|---|
690
- | 3 | **flagged** (0.563) | 0.000 — under the 80-character floor |
691
- | 5 | flagged (0.682) | 0.000 |
692
- | 9 | flagged (0.794) | 0.686 |
693
- | 10 | flagged (0.811) | **flagged** (0.708) |
694
- | 20 | flagged (0.900) | flagged (0.829) |
695
-
696
- **English flags at 3 repeats, Chinese at about 10** — and nearer 30 when a long
697
- healthy passage precedes the loop, because the score is coverage of the trailing
698
- window rather than a count.
699
-
700
- This is a decision, not an accident of two constants. Word mode counts tokens,
701
- so a repeated clause is several tokens and accumulates fast. Character mode
702
- measures how much of a fixed trailing window one repeating block covers, and a
703
- short refrain takes many repeats to fill it. Tightening `maxCharTailLoop` toward
704
- word-mode aggression would put it into the range where ordinary CJK structured
705
- output sits, which is the trade this package refuses.
706
-
707
- **The practical consequence: a looping model answering in Chinese, Japanese or
708
- Thai generates several times more output before the guard fires than the same
709
- model looping in English.** Detection is later and the token saving is smaller.
710
- If you serve mostly non-spaced-script traffic and that cost matters more to you
711
- than the false-positive risk, lower `maxCharTailLoop` toward 0.5 — and calibrate
712
- it against your own traffic first, because that is the range healthy structured
713
- output starts to reach.
714
-
715
205
  ## Stability
716
206
 
717
207
  What semver means for this package specifically. These rules bind from **1.0.0**
@@ -772,6 +262,7 @@ patches.
772
262
  - `REPETITION` does not work on Chinese, Japanese or Thai. See above — this is a known, measured gap, not an oversight.
773
263
  - Language detection is a function-word heuristic covering `id`/`en`/`es`. Opt-in, and unreliable under 25 words.
774
264
  - Truncation from a missing full stop is weak evidence, scored 0.55 and left below the default thresholds on purpose. Lower `maxTruncation` to ~0.5 to catch it, and expect false positives.
265
+ - A JSON array of repeated identical records reads as a loop under the default scope, and fails from three records up. Set `redundancyScope: 'jsonValues'` — see **Structured output**.
775
266
  - Thresholds calibrated on the bundled corpus. Yours will differ — and the word and character thresholds need calibrating **separately**, because they are separate distributions.
776
267
 
777
268
  ## License