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