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 +76 -615
- package/dist/ai-sdk.cjs +13 -2
- package/dist/ai-sdk.cjs.map +1 -1
- package/dist/ai-sdk.js +2 -2
- package/dist/anthropic.cjs +13 -2
- package/dist/anthropic.cjs.map +1 -1
- package/dist/anthropic.js +3 -3
- package/dist/{chunk-6G6BDPQP.js → chunk-7MX4T5N4.js} +4 -4
- package/dist/{chunk-6G6BDPQP.js.map → chunk-7MX4T5N4.js.map} +1 -1
- package/dist/{chunk-Q6W2JCSE.js → chunk-NHWU3RAY.js} +3 -3
- package/dist/{chunk-Q6W2JCSE.js.map → chunk-NHWU3RAY.js.map} +1 -1
- package/dist/{chunk-T4DJ6IFG.js → chunk-QK33F5WY.js} +15 -4
- package/dist/chunk-QK33F5WY.js.map +1 -0
- package/dist/index.cjs +13 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +0 -10
- package/dist/index.d.ts +0 -10
- package/dist/index.js +1 -1
- package/dist/openai.cjs +13 -2
- package/dist/openai.cjs.map +1 -1
- package/dist/openai.js +3 -3
- package/package.json +1 -1
- package/dist/chunk-T4DJ6IFG.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,73 +1,89 @@
|
|
|
1
1
|
# llm-output-guard
|
|
2
2
|
|
|
3
|
-
Detect LLM responses that failed
|
|
3
|
+
**Detect LLM responses that failed while returning `200 OK`.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/llm-output-guard)
|
|
6
|
+
[](https://bundlephobia.com/package/llm-output-guard)
|
|
7
|
+
[](https://github.com/edwinsatya/llm-output-guard/blob/main/package.json)
|
|
8
|
+
[](https://github.com/edwinsatya/llm-output-guard/actions/workflows/ci.yml)
|
|
9
|
+
[](./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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
---
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
- [`ai-fallback`](https://www.npmjs.com/package/ai-fallback) — model fallback for the Vercel AI SDK
|
|
42
|
+
## What it catches
|
|
35
43
|
|
|
36
|
-
|
|
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
|
-
##
|
|
59
|
+
## Guard your provider in one wrap
|
|
41
60
|
|
|
42
61
|
```ts
|
|
43
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
526
|
-
Log your scores for a week, then
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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**
|