llm-output-guard 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,6 +76,47 @@ const verdict = checkOutput(raw, {
76
76
  if (verdict.ok) use(verdict.json); // already parsed, fence stripped
77
77
  ```
78
78
 
79
+ `requiredKeys` only asks whether a name is present. A model returning
80
+ `{ "score": "very good" }` where you wanted a number satisfies it and still
81
+ breaks everything downstream that does arithmetic. Pass a **schema** to check
82
+ the shape rather than the spelling:
83
+
84
+ ```ts
85
+ import { z } from 'zod';
86
+
87
+ const Review = z.object({
88
+ score: z.number().min(0).max(10),
89
+ notes: z.string(),
90
+ followUp: z.array(z.string()),
91
+ });
92
+
93
+ const verdict = checkOutput(raw, { ...presets.strictJson, schema: Review });
94
+
95
+ if (verdict.ok) use(verdict.json); // parsed, validated, defaults applied
96
+ ```
97
+
98
+ Any [Standard Schema](https://standardschema.dev) validator works — **Zod 4,
99
+ Valibot, ArkType**, or your own. The spec is types-only, so this costs an
100
+ interface and **no dependency**: your validator is one you already have, and
101
+ `llm-output-guard` still installs with nothing behind it.
102
+
103
+ On success `verdict.json` is the schema's *output*, so defaults, coercions and
104
+ transforms are applied and the value matches the type you declared. On failure
105
+ you get `INVALID_JSON` with the failing path in the message —
106
+ `score: Expected number, received string`. It is the same reason code as a
107
+ missing key or an unparseable payload because it wants the same handling: retry,
108
+ or fall through to another provider.
109
+
110
+ The two compose, and keys are checked first, so a missing key is still reported
111
+ as a missing key rather than as whatever the schema calls it.
112
+
113
+ > **The schema must validate synchronously.** `checkOutput` is synchronous by
114
+ > design — that is what makes it safe on a hot path — so a schema carrying an
115
+ > async refinement throws a `TypeError` telling you so, rather than silently
116
+ > passing. Everything Zod, Valibot and ArkType produce otherwise is synchronous.
117
+ > This is the one thing in the package that throws about your configuration; it
118
+ > still never throws about a response.
119
+
79
120
  ### Streaming, where it stops costing you tokens
80
121
 
81
122
  Checking a finished response tells you that you already paid for it. A model
@@ -213,7 +254,8 @@ call `checkOutput` on the result yourself.
213
254
 
214
255
  ### OpenAI SDK — and anything speaking its protocol
215
256
 
216
- One wrap, and both call shapes are guarded:
257
+ One wrap, and both APIs are guarded — `chat.completions.create` and
258
+ `responses.create`, streaming and not:
217
259
 
218
260
  ```ts
219
261
  import OpenAI from 'openai';
@@ -224,12 +266,30 @@ const client = withOutputGuard(new OpenAI(), {
224
266
  ...presets.chat,
225
267
  onDegenerate: 'abort',
226
268
  });
269
+
270
+ await client.chat.completions.create({ model, messages }); // guarded
271
+ await client.responses.create({ model, input }); // guarded
227
272
  ```
228
273
 
274
+ The Responses API spells its stop reason `incomplete_details.reason` rather than
275
+ `finish_reason`, and its length stop `max_output_tokens` rather than `length`.
276
+ Both are mapped, so `TRUNCATED` fires the same way on either. `content_filter`
277
+ is deliberately *not* read as truncation — a filtered response is a different
278
+ failure, and reporting it as `TRUNCATED` would send a retry layer after the
279
+ wrong fix.
280
+
281
+ > **`responses.stream()` is not guarded.** It returns a `ResponseStream` — an
282
+ > event emitter with `.on()` and `.finalResponse()`, not just an async iterable
283
+ > — and wrapping only its iteration would guard a `for await` consumer while
284
+ > leaving `.finalResponse()` unchecked. A guard you believe in and do not have
285
+ > is the failure this package was written about, so it is left plainly
286
+ > unguarded rather than half-wrapped. Use `create({ stream: true })`, which is
287
+ > guarded, or run `checkOutput` on `await stream.finalResponse()` yourself.
288
+
229
289
  This is also how you guard **Groq, Together, OpenRouter, Fireworks, DeepInfra,
230
290
  vLLM and Ollama** — anything you reach through an OpenAI-compatible `baseURL`
231
- works, because the adapter is typed against the chat-completions shape rather
232
- than against OpenAI the company.
291
+ works, because the adapter is typed against the wire shapes rather than against
292
+ OpenAI the company.
233
293
 
234
294
  Non-streaming calls are already paid for by the time anything can run, so a
235
295
  degenerate one throws `DegenerateOutputError` for your fallback layer to catch:
@@ -288,8 +348,80 @@ deferred to `end()`. `LOW_ENTROPY` is deferred too, for cost — it is ~100x the
288
348
  other detectors, and everything it would have caught early is caught by
289
349
  `REPETITION`, or by `TAIL_LOOP`'s character mode on non-spaced scripts.
290
350
 
291
- Both adapters share this behaviour because both drive the same
292
- `createStreamGuard`. Neither reimplements it.
351
+ Every adapter shares this behaviour because they all drive the same
352
+ `createStreamGuard`. None of them reimplements it.
353
+
354
+ ### Anthropic SDK
355
+
356
+ Same one wrap, same options:
357
+
358
+ ```ts
359
+ import Anthropic from '@anthropic-ai/sdk';
360
+ import { withOutputGuard } from 'llm-output-guard/anthropic';
361
+ import { presets } from 'llm-output-guard';
362
+
363
+ const client = withOutputGuard(new Anthropic(), {
364
+ ...presets.chat,
365
+ onDegenerate: 'abort',
366
+ });
367
+
368
+ await client.messages.create({ model, max_tokens, messages }); // guarded
369
+ await client.messages.create({ model, max_tokens, messages, stream: true }); // guarded
370
+ ```
371
+
372
+ Two things are specific to this API:
373
+
374
+ **Extended thinking is not read as the answer.** `thinking` blocks are the
375
+ model's reasoning, they are often longer than the answer, and they repeat
376
+ themselves as a matter of course while working a problem. Folding them into the
377
+ measured text would raise every repetition score on every thinking response and
378
+ flag the ones that thought hardest — so only `text` blocks are measured, and a
379
+ `thinking` block is not mistaken for a tool call either.
380
+
381
+ **Both of Anthropic's length stops map to `TRUNCATED`.** `max_tokens` passes
382
+ straight through; `model_context_window_exceeded` is the same event under a
383
+ different name and is normalised in the adapter. `refusal` is deliberately *not*
384
+ truncation — a refusal is a complete response that says no, which is a content
385
+ judgement this package does not make.
386
+
387
+ > **`messages.stream()` is not guarded**, for the same reason `responses.stream()`
388
+ > isn't: it returns a `MessageStream` — an event emitter with `.on()` and
389
+ > `.finalMessage()` — and guarding only its iteration would leave
390
+ > `.finalMessage()` unchecked. Use `create({ stream: true })`, or run
391
+ > `checkOutput` on `await stream.finalMessage()` yourself. `messages.batches` is
392
+ > unguarded too, and less interestingly: a batch is retrieved later as a file of
393
+ > results, so there is no response at `create` time to inspect.
394
+
395
+ `@anthropic-ai/sdk` is an **optional peer dependency**, declared
396
+ `>=0.60.0 <1.0.0` and verified at 0.60.0, 0.90.0 and 0.117.1 — each installing
397
+ the packed tarball and running the adapter for real, not just typechecking.
398
+
399
+ ### Tool calls and agents
400
+
401
+ A model that answers by calling a tool returns no assistant text — OpenAI sends
402
+ `content: null` beside `tool_calls`, and the AI SDK sends a `content` array with
403
+ no `text` part. Handed to `checkOutput`, that is an empty string, and an empty
404
+ string scores `EMPTY`.
405
+
406
+ So **the presence of tool calls means the text, if any, is a preamble rather
407
+ than the answer**, and both adapters judge it as one:
408
+
409
+ | | On a tool-calling turn |
410
+ |---|---|
411
+ | No text at all | Nothing is judged, and nothing is reported to `onVerdict` |
412
+ | Text beside the call | `REPETITION`, `TAIL_LOOP` and `LOW_ENTROPY` still run |
413
+ | `TOO_SHORT` | Off — "Let me look that up" is sixteen characters and correct |
414
+ | `TRUNCATED` | Off — a preamble ends without terminal punctuation as a matter of course |
415
+ | `INVALID_JSON` | Off — the JSON is in the call arguments, which your provider already validated against the schema |
416
+
417
+ The redundancy detectors stay on because a model looping in its preamble is
418
+ still a model that is looping. `EMPTY` is not disarmed either: a response with
419
+ neither text nor tool calls still fails, which is the case this package exists
420
+ for.
421
+
422
+ Nothing is reported to `onVerdict` for a text-free tool call on purpose. Those
423
+ samples are what a `calibrate` run is built from, and a spike of `EMPTY: 1` in
424
+ them would describe your agent's tool use rather than any degeneration.
293
425
 
294
426
  ---
295
427
 
@@ -318,18 +450,34 @@ gives you a number that describes neither.
318
450
 
319
451
  ## Detectors
320
452
 
321
- | Code | Catches | Signal |
322
- |---|---|---|
323
- | `EMPTY` | Whitespace, lone punctuation, `{}`, empty fences | Content presence |
324
- | `TOO_SHORT` | Non-empty but useless | Length vs. minimum |
325
- | `REPETITION` | Loops and stutters | Duplicate word n-gram fraction |
326
- | `TAIL_LOOP` | Good start, then a stuck ending | Periodicity in the trailing window, over words or characters |
327
- | `LOW_ENTROPY` | Character-level collapse, token artifacts | Hand-rolled LZ77 compression ratio |
328
- | `TRUNCATED` | Cut off mid-thought | `finish_reason`, unbalanced fences/brackets |
329
- | `INVALID_JSON` | Prose around the payload, missing keys | Parse + key contract |
330
- | `LANG_MISMATCH` | Answered in the wrong language | Function-word profile (coarse, opt-in) |
453
+ | Code | Catches | Signal | Exported as |
454
+ |---|---|---|---|
455
+ | `EMPTY` | Whitespace, lone punctuation, `{}`, empty fences | Content presence | `emptinessScore` |
456
+ | `TOO_SHORT` | Non-empty but useless | Length vs. minimum | `shortnessScore` |
457
+ | `REPETITION` | Loops and stutters | Duplicate word n-gram fraction | `repetitionScore` |
458
+ | `TAIL_LOOP` | Good start, then a stuck ending | Periodicity in the trailing window, over words or characters | `tailLoopScore`, `tailLoopDetail` |
459
+ | `LOW_ENTROPY` | Character-level collapse, token artifacts | Hand-rolled LZ77 compression ratio | `compressibilityScore`, `compressionRatio` |
460
+ | `TRUNCATED` | Cut off mid-thought | `finish_reason`, unbalanced fences/brackets | `truncationScore` |
461
+ | `INVALID_JSON` | Prose around the payload, missing keys, wrong types | Parse + key contract + optional schema | `jsonScore`, `stripFence` |
462
+ | `LANG_MISMATCH` | Answered in the wrong language | Function-word profile (coarse, opt-in) | `languageMismatchScore`, `languageProfile`, `supportedLanguages` |
463
+
464
+ Every detector is exported on its own if you only want one, and every name in
465
+ that last column is covered by semver — see **Stability**.
331
466
 
332
- Every detector is exported on its own if you only want one.
467
+ ```ts
468
+ import { repetitionScore, tailLoopDetail, stripFence } from 'llm-output-guard';
469
+
470
+ repetitionScore(text); // 0..1, higher is worse
471
+ repetitionScore(text, { n: 4 }); // n-gram size
472
+ tailLoopDetail(text, { mode: 'char' }); // { score, mode } — which tokenizer ran
473
+ stripFence('```json\n{"a":1}\n```'); // '{"a":1}'
474
+ ```
475
+
476
+ Each takes `(text, options?)` and returns a `0..1` score, with three exceptions
477
+ worth knowing: `shortnessScore(text, minChars)` takes its minimum positionally,
478
+ `stripFence` returns a string, and `jsonScore` / `tailLoopDetail` return a detail
479
+ object rather than a bare number. `supportedLanguages` is a value, not a
480
+ function — the array `['id', 'en', 'es']`.
333
481
 
334
482
  ## Presets
335
483
 
@@ -390,6 +538,42 @@ the bulk and a cluster of outliers is real separation observed in your data
390
538
  rather than an assumption about rarity — and when that hole rests on one or
391
539
  two samples, the report says so.
392
540
 
541
+ ### The same thing, as a function
542
+
543
+ The CLI is a wrapper. If your scores already live somewhere the shell cannot
544
+ reach them — a metrics store, a warehouse query, a test — call `calibrate`
545
+ directly. It takes the same flat objects the JSONL format describes:
546
+
547
+ ```ts
548
+ import { calibrate } from 'llm-output-guard';
549
+
550
+ const { n, summaries } = calibrate(
551
+ [
552
+ { REPETITION: 0.03, TAIL_LOOP: 0 },
553
+ { REPETITION: 0.91, TAIL_LOOP: 0.88, modes: { TAIL_LOOP: 'char' } },
554
+ // ...one entry per logged verdict
555
+ ],
556
+ { falsePositiveRate: 0.001 },
557
+ );
558
+
559
+ for (const s of summaries) {
560
+ s.code; // 'REPETITION'
561
+ s.mode; // 'word' | 'char', when the samples recorded one
562
+ s.suggested; // threshold flagging falsePositiveRate of this sample
563
+ s.gap; // { below, above, count, share } | null — stronger evidence
564
+ s.distribution; // { n, nonZero, min, max, p50, p90, p99, p999 }
565
+ s.caveats; // everything that makes `suggested` untrustworthy
566
+ }
567
+ ```
568
+
569
+ `modes` rides along in the same object and is not read as a score. Log it, and
570
+ `summaries` comes back segmented — one entry per `code`+`mode` — for the reason
571
+ in the paragraph above. `summarise(code, scores, options)` is exported too, for
572
+ when you have one detector's numbers already grouped.
573
+
574
+ **Read `caveats` before `suggested`.** It is where a sample too small for the
575
+ requested rate says so, and a `suggested` number carries no warning of its own.
576
+
393
577
  ## On thresholds
394
578
 
395
579
  A miss is annoying. **A false positive is worse**: a healthy response gets discarded and retried against a slower provider for nothing.
@@ -531,13 +715,15 @@ onward; under `0.x` they described an intent, and the surface was frozen — exp
531
715
  by export — in the 1.0.0 release.
532
716
 
533
717
  **The public API is:** everything exported from `llm-output-guard`, plus
534
- `outputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./ai-sdk` and
535
- `withOutputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./openai`.
536
- Each subpath is its own contract; the two adapters share an internal base type
537
- today and are free to diverge, so an option added to one is not a promise about
538
- the other. Anything not exported from those three entry points is internal, has
539
- no stability guarantee, and may move in any release. The list is asserted in
540
- `test/surface.test.ts`, so an export cannot join it by accident.
718
+ `outputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./ai-sdk`, and
719
+ `withOutputGuard` / `OutputGuardOptions` / `DegenerateAction` from each of
720
+ `./openai` and `./anthropic`. Each subpath is its own contract; the adapters
721
+ share internal base types today and are free to diverge, so an option added to
722
+ one is not a promise about the others. Anything not exported from those four
723
+ entry points is internal, has no stability guarantee, and may move in any release
724
+ `internal/proxy-guard.ts` and `internal/tool-calls.ts` included, however much
725
+ behaviour they carry. The list is asserted in `test/surface.test.ts`, so an
726
+ export cannot join it by accident.
541
727
 
542
728
  **Threshold and preset values are behaviour, not implementation.** This is the
543
729
  interesting case, so it gets a rule of its own:
@@ -577,6 +763,8 @@ patches.
577
763
  ## Limitations
578
764
 
579
765
  - Not a hallucination detector. It measures *shape*, never truth.
766
+ - Tool *arguments* are not checked, only the prose beside them. A model that loops inside a JSON argument string is invisible here — your provider validates those against the schema you gave it.
767
+ - `openai`'s `responses.stream()` helper is not wrapped. See the note above; `create({ stream: true })` is.
580
768
  - `REPETITION` does not work on Chinese, Japanese or Thai. See above — this is a known, measured gap, not an oversight.
581
769
  - Language detection is a function-word heuristic covering `id`/`en`/`es`. Opt-in, and unreliable under 25 words.
582
770
  - 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.
@@ -1,4 +1,4 @@
1
- import { V as Verdict } from './stream-Be2WhoKn.js';
1
+ import { V as Verdict } from './stream-D-GVZ6iE.js';
2
2
 
3
3
  /**
4
4
  * The option surface every provider adapter shares.
@@ -1,4 +1,4 @@
1
- import { V as Verdict } from './stream-Be2WhoKn.cjs';
1
+ import { V as Verdict } from './stream-D-GVZ6iE.cjs';
2
2
 
3
3
  /**
4
4
  * The option surface every provider adapter shares.
package/dist/ai-sdk.cjs CHANGED
@@ -154,8 +154,14 @@ function stripFence(text) {
154
154
  const fenced = text.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
155
155
  return fenced ? fenced[1] : text.trim();
156
156
  }
157
+ function describe(issue) {
158
+ const path = (issue.path ?? []).map(
159
+ (segment) => typeof segment === "object" && segment !== null && "key" in segment ? String(segment.key) : String(segment)
160
+ ).join(".");
161
+ return path ? `${path}: ${issue.message}` : issue.message;
162
+ }
157
163
  function jsonScore(text, options = {}) {
158
- const { allowFence = true, requiredKeys = [] } = options;
164
+ const { allowFence = true, requiredKeys = [], schema } = options;
159
165
  const candidate = allowFence ? stripFence(text) : text.trim();
160
166
  let value;
161
167
  try {
@@ -173,6 +179,19 @@ function jsonScore(text, options = {}) {
173
179
  return { score: 1, value, reason: "missing-keys", missingKeys: missing };
174
180
  }
175
181
  }
182
+ if (schema) {
183
+ const result = schema["~standard"].validate(value);
184
+ if (typeof result?.then === "function") {
185
+ throw new TypeError(
186
+ "llm-output-guard: `schema` must validate synchronously, and this one returned a promise. checkOutput is synchronous by design. Remove the async refinement, or validate the payload yourself after checkOutput returns."
187
+ );
188
+ }
189
+ const sync = result;
190
+ if (sync.issues) {
191
+ return { score: 1, value, reason: "schema", issues: sync.issues.map(describe) };
192
+ }
193
+ return { score: 0, value: sync.value };
194
+ }
176
195
  return { score: 0, value };
177
196
  }
178
197
 
@@ -297,14 +316,15 @@ function checkOutput(text, options = {}) {
297
316
  if (opts.expectJson) {
298
317
  const result = jsonScore(text, {
299
318
  allowFence: opts.allowJsonFence,
300
- requiredKeys: opts.requiredKeys
319
+ requiredKeys: opts.requiredKeys,
320
+ schema: opts.schema
301
321
  });
302
322
  parsedJson = result.value;
303
323
  add(
304
324
  "INVALID_JSON",
305
325
  result.score,
306
326
  0,
307
- result.reason === "missing-keys" ? `JSON is missing required keys: ${result.missingKeys?.join(", ")}.` : "Response is not parseable JSON."
327
+ result.reason === "missing-keys" ? `JSON is missing required keys: ${result.missingKeys?.join(", ")}.` : result.reason === "schema" ? `JSON does not match the schema: ${result.issues?.join("; ")}.` : "Response is not parseable JSON."
308
328
  );
309
329
  }
310
330
  if (opts.expectLang) {
@@ -405,7 +425,20 @@ function createStreamGuard(options = {}) {
405
425
  };
406
426
  }
407
427
 
428
+ // src/internal/tool-calls.ts
429
+ var TOOL_CALL_PREAMBLE = {
430
+ minLength: 0,
431
+ maxTruncation: null,
432
+ expectJson: false,
433
+ finishReason: void 0
434
+ };
435
+ function checkPreamble(text, options) {
436
+ if (text.trim().length === 0) return null;
437
+ return checkOutput(text, { ...options, ...TOOL_CALL_PREAMBLE });
438
+ }
439
+
408
440
  // src/ai-sdk.ts
441
+ var isToolPart = (part) => part.type.startsWith("tool-");
409
442
  function finishReasonOf(value) {
410
443
  if (typeof value === "string") return value;
411
444
  if (value && typeof value === "object") return value.unified ?? value.raw;
@@ -443,7 +476,13 @@ function outputGuard(options = {}) {
443
476
  doGenerate
444
477
  }) {
445
478
  const result = await doGenerate();
446
- const text = (result.content ?? []).filter((part) => part.type === "text").map((part) => part.text ?? "").join("");
479
+ const content = result.content ?? [];
480
+ const text = content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("");
481
+ if (content.some(isToolPart)) {
482
+ const verdict = checkPreamble(text, guardOptions);
483
+ if (verdict) act(verdict, false);
484
+ return result;
485
+ }
447
486
  act(
448
487
  checkOutput(text, {
449
488
  ...guardOptions,
@@ -459,12 +498,14 @@ function outputGuard(options = {}) {
459
498
  const result = await doStream();
460
499
  const guard = createStreamGuard(guardOptions);
461
500
  let fired = false;
501
+ let sawToolCall = false;
462
502
  let finishReason;
463
503
  const guarded = result.stream.pipeThrough(
464
504
  new TransformStream({
465
505
  transform(part, controller) {
466
506
  controller.enqueue(part);
467
507
  if (part.type === "finish") finishReason = part.finishReason;
508
+ if (isToolPart(part)) sawToolCall = true;
468
509
  if (part.type !== "text-delta" || fired) return;
469
510
  const verdict = guard.push(part.delta ?? "");
470
511
  if (!verdict || verdict.ok) return;
@@ -478,9 +519,13 @@ function outputGuard(options = {}) {
478
519
  }
479
520
  },
480
521
  flush() {
481
- if (!fired) {
482
- onVerdict?.(guard.end(finishReasonOf(finishReason)), { streaming: true });
522
+ if (fired) return;
523
+ if (sawToolCall) {
524
+ const verdict = checkPreamble(guard.text, guardOptions);
525
+ if (verdict) onVerdict?.(verdict, { streaming: true });
526
+ return;
483
527
  }
528
+ onVerdict?.(guard.end(finishReasonOf(finishReason)), { streaming: true });
484
529
  }
485
530
  })
486
531
  );