llm-output-guard 1.0.1 → 1.2.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
@@ -8,6 +8,10 @@ Zero runtime dependencies. Deterministic. Composes with whatever retry or fallba
8
8
  npm i llm-output-guard
9
9
  ```
10
10
 
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
+
11
15
  ---
12
16
 
13
17
  ## Why this exists
@@ -76,6 +80,47 @@ const verdict = checkOutput(raw, {
76
80
  if (verdict.ok) use(verdict.json); // already parsed, fence stripped
77
81
  ```
78
82
 
83
+ `requiredKeys` only asks whether a name is present. A model returning
84
+ `{ "score": "very good" }` where you wanted a number satisfies it and still
85
+ breaks everything downstream that does arithmetic. Pass a **schema** to check
86
+ the shape rather than the spelling:
87
+
88
+ ```ts
89
+ import { z } from 'zod';
90
+
91
+ const Review = z.object({
92
+ score: z.number().min(0).max(10),
93
+ notes: z.string(),
94
+ followUp: z.array(z.string()),
95
+ });
96
+
97
+ const verdict = checkOutput(raw, { ...presets.strictJson, schema: Review });
98
+
99
+ if (verdict.ok) use(verdict.json); // parsed, validated, defaults applied
100
+ ```
101
+
102
+ Any [Standard Schema](https://standardschema.dev) validator works — **Zod 4,
103
+ Valibot, ArkType**, or your own. The spec is types-only, so this costs an
104
+ interface and **no dependency**: your validator is one you already have, and
105
+ `llm-output-guard` still installs with nothing behind it.
106
+
107
+ On success `verdict.json` is the schema's *output*, so defaults, coercions and
108
+ transforms are applied and the value matches the type you declared. On failure
109
+ you get `INVALID_JSON` with the failing path in the message —
110
+ `score: Expected number, received string`. It is the same reason code as a
111
+ missing key or an unparseable payload because it wants the same handling: retry,
112
+ or fall through to another provider.
113
+
114
+ The two compose, and keys are checked first, so a missing key is still reported
115
+ as a missing key rather than as whatever the schema calls it.
116
+
117
+ > **The schema must validate synchronously.** `checkOutput` is synchronous by
118
+ > design — that is what makes it safe on a hot path — so a schema carrying an
119
+ > async refinement throws a `TypeError` telling you so, rather than silently
120
+ > passing. Everything Zod, Valibot and ArkType produce otherwise is synchronous.
121
+ > This is the one thing in the package that throws about your configuration; it
122
+ > still never throws about a response.
123
+
79
124
  ### Streaming, where it stops costing you tokens
80
125
 
81
126
  Checking a finished response tells you that you already paid for it. A model
@@ -213,7 +258,8 @@ call `checkOutput` on the result yourself.
213
258
 
214
259
  ### OpenAI SDK — and anything speaking its protocol
215
260
 
216
- One wrap, and both call shapes are guarded:
261
+ One wrap, and both APIs are guarded — `chat.completions.create` and
262
+ `responses.create`, streaming and not:
217
263
 
218
264
  ```ts
219
265
  import OpenAI from 'openai';
@@ -224,12 +270,30 @@ const client = withOutputGuard(new OpenAI(), {
224
270
  ...presets.chat,
225
271
  onDegenerate: 'abort',
226
272
  });
273
+
274
+ await client.chat.completions.create({ model, messages }); // guarded
275
+ await client.responses.create({ model, input }); // guarded
227
276
  ```
228
277
 
278
+ The Responses API spells its stop reason `incomplete_details.reason` rather than
279
+ `finish_reason`, and its length stop `max_output_tokens` rather than `length`.
280
+ Both are mapped, so `TRUNCATED` fires the same way on either. `content_filter`
281
+ is deliberately *not* read as truncation — a filtered response is a different
282
+ failure, and reporting it as `TRUNCATED` would send a retry layer after the
283
+ wrong fix.
284
+
285
+ > **`responses.stream()` is not guarded.** It returns a `ResponseStream` — an
286
+ > event emitter with `.on()` and `.finalResponse()`, not just an async iterable
287
+ > — and wrapping only its iteration would guard a `for await` consumer while
288
+ > leaving `.finalResponse()` unchecked. A guard you believe in and do not have
289
+ > is the failure this package was written about, so it is left plainly
290
+ > unguarded rather than half-wrapped. Use `create({ stream: true })`, which is
291
+ > guarded, or run `checkOutput` on `await stream.finalResponse()` yourself.
292
+
229
293
  This is also how you guard **Groq, Together, OpenRouter, Fireworks, DeepInfra,
230
294
  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.
295
+ works, because the adapter is typed against the wire shapes rather than against
296
+ OpenAI the company.
233
297
 
234
298
  Non-streaming calls are already paid for by the time anything can run, so a
235
299
  degenerate one throws `DegenerateOutputError` for your fallback layer to catch:
@@ -288,8 +352,80 @@ deferred to `end()`. `LOW_ENTROPY` is deferred too, for cost — it is ~100x the
288
352
  other detectors, and everything it would have caught early is caught by
289
353
  `REPETITION`, or by `TAIL_LOOP`'s character mode on non-spaced scripts.
290
354
 
291
- Both adapters share this behaviour because both drive the same
292
- `createStreamGuard`. Neither reimplements it.
355
+ Every adapter shares this behaviour because they all drive the same
356
+ `createStreamGuard`. None of them reimplements it.
357
+
358
+ ### Anthropic SDK
359
+
360
+ Same one wrap, same options:
361
+
362
+ ```ts
363
+ import Anthropic from '@anthropic-ai/sdk';
364
+ import { withOutputGuard } from 'llm-output-guard/anthropic';
365
+ import { presets } from 'llm-output-guard';
366
+
367
+ const client = withOutputGuard(new Anthropic(), {
368
+ ...presets.chat,
369
+ onDegenerate: 'abort',
370
+ });
371
+
372
+ await client.messages.create({ model, max_tokens, messages }); // guarded
373
+ await client.messages.create({ model, max_tokens, messages, stream: true }); // guarded
374
+ ```
375
+
376
+ Two things are specific to this API:
377
+
378
+ **Extended thinking is not read as the answer.** `thinking` blocks are the
379
+ model's reasoning, they are often longer than the answer, and they repeat
380
+ themselves as a matter of course while working a problem. Folding them into the
381
+ measured text would raise every repetition score on every thinking response and
382
+ flag the ones that thought hardest — so only `text` blocks are measured, and a
383
+ `thinking` block is not mistaken for a tool call either.
384
+
385
+ **Both of Anthropic's length stops map to `TRUNCATED`.** `max_tokens` passes
386
+ straight through; `model_context_window_exceeded` is the same event under a
387
+ different name and is normalised in the adapter. `refusal` is deliberately *not*
388
+ truncation — a refusal is a complete response that says no, which is a content
389
+ judgement this package does not make.
390
+
391
+ > **`messages.stream()` is not guarded**, for the same reason `responses.stream()`
392
+ > isn't: it returns a `MessageStream` — an event emitter with `.on()` and
393
+ > `.finalMessage()` — and guarding only its iteration would leave
394
+ > `.finalMessage()` unchecked. Use `create({ stream: true })`, or run
395
+ > `checkOutput` on `await stream.finalMessage()` yourself. `messages.batches` is
396
+ > unguarded too, and less interestingly: a batch is retrieved later as a file of
397
+ > results, so there is no response at `create` time to inspect.
398
+
399
+ `@anthropic-ai/sdk` is an **optional peer dependency**, declared
400
+ `>=0.60.0 <1.0.0` and verified at 0.60.0, 0.90.0 and 0.117.1 — each installing
401
+ the packed tarball and running the adapter for real, not just typechecking.
402
+
403
+ ### Tool calls and agents
404
+
405
+ A model that answers by calling a tool returns no assistant text — OpenAI sends
406
+ `content: null` beside `tool_calls`, and the AI SDK sends a `content` array with
407
+ no `text` part. Handed to `checkOutput`, that is an empty string, and an empty
408
+ string scores `EMPTY`.
409
+
410
+ So **the presence of tool calls means the text, if any, is a preamble rather
411
+ than the answer**, and both adapters judge it as one:
412
+
413
+ | | On a tool-calling turn |
414
+ |---|---|
415
+ | No text at all | Nothing is judged, and nothing is reported to `onVerdict` |
416
+ | Text beside the call | `REPETITION`, `TAIL_LOOP` and `LOW_ENTROPY` still run |
417
+ | `TOO_SHORT` | Off — "Let me look that up" is sixteen characters and correct |
418
+ | `TRUNCATED` | Off — a preamble ends without terminal punctuation as a matter of course |
419
+ | `INVALID_JSON` | Off — the JSON is in the call arguments, which your provider already validated against the schema |
420
+
421
+ The redundancy detectors stay on because a model looping in its preamble is
422
+ still a model that is looping. `EMPTY` is not disarmed either: a response with
423
+ neither text nor tool calls still fails, which is the case this package exists
424
+ for.
425
+
426
+ Nothing is reported to `onVerdict` for a text-free tool call on purpose. Those
427
+ samples are what a `calibrate` run is built from, and a spike of `EMPTY: 1` in
428
+ them would describe your agent's tool use rather than any degeneration.
293
429
 
294
430
  ---
295
431
 
@@ -326,7 +462,7 @@ gives you a number that describes neither.
326
462
  | `TAIL_LOOP` | Good start, then a stuck ending | Periodicity in the trailing window, over words or characters | `tailLoopScore`, `tailLoopDetail` |
327
463
  | `LOW_ENTROPY` | Character-level collapse, token artifacts | Hand-rolled LZ77 compression ratio | `compressibilityScore`, `compressionRatio` |
328
464
  | `TRUNCATED` | Cut off mid-thought | `finish_reason`, unbalanced fences/brackets | `truncationScore` |
329
- | `INVALID_JSON` | Prose around the payload, missing keys | Parse + key contract | `jsonScore`, `stripFence` |
465
+ | `INVALID_JSON` | Prose around the payload, missing keys, wrong types | Parse + key contract + optional schema | `jsonScore`, `stripFence` |
330
466
  | `LANG_MISMATCH` | Answered in the wrong language | Function-word profile (coarse, opt-in) | `languageMismatchScore`, `languageProfile`, `supportedLanguages` |
331
467
 
332
468
  Every detector is exported on its own if you only want one, and every name in
@@ -583,13 +719,15 @@ onward; under `0.x` they described an intent, and the surface was frozen — exp
583
719
  by export — in the 1.0.0 release.
584
720
 
585
721
  **The public API is:** everything exported from `llm-output-guard`, plus
586
- `outputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./ai-sdk` and
587
- `withOutputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./openai`.
588
- Each subpath is its own contract; the two adapters share an internal base type
589
- today and are free to diverge, so an option added to one is not a promise about
590
- the other. Anything not exported from those three entry points is internal, has
591
- no stability guarantee, and may move in any release. The list is asserted in
592
- `test/surface.test.ts`, so an export cannot join it by accident.
722
+ `outputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./ai-sdk`, and
723
+ `withOutputGuard` / `OutputGuardOptions` / `DegenerateAction` from each of
724
+ `./openai` and `./anthropic`. Each subpath is its own contract; the adapters
725
+ share internal base types today and are free to diverge, so an option added to
726
+ one is not a promise about the others. Anything not exported from those four
727
+ entry points is internal, has no stability guarantee, and may move in any release
728
+ `internal/proxy-guard.ts` and `internal/tool-calls.ts` included, however much
729
+ behaviour they carry. The list is asserted in `test/surface.test.ts`, so an
730
+ export cannot join it by accident.
593
731
 
594
732
  **Threshold and preset values are behaviour, not implementation.** This is the
595
733
  interesting case, so it gets a rule of its own:
@@ -629,6 +767,8 @@ patches.
629
767
  ## Limitations
630
768
 
631
769
  - Not a hallucination detector. It measures *shape*, never truth.
770
+ - 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.
771
+ - `openai`'s `responses.stream()` helper is not wrapped. See the note above; `create({ stream: true })` is.
632
772
  - `REPETITION` does not work on Chinese, Japanese or Thai. See above — this is a known, measured gap, not an oversight.
633
773
  - Language detection is a function-word heuristic covering `id`/`en`/`es`. Opt-in, and unreliable under 25 words.
634
774
  - 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
  );