llm-output-guard 1.3.1 → 1.4.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.
Files changed (37) hide show
  1. package/README.md +36 -5
  2. package/dist/{adapter-options-ClbcmvaY.d.ts → adapter-options-BUOzVp3T.d.ts} +1 -1
  3. package/dist/{adapter-options-DpTwyLVK.d.cts → adapter-options-CQNPNjES.d.cts} +1 -1
  4. package/dist/ai-sdk.cjs +81 -0
  5. package/dist/ai-sdk.cjs.map +1 -1
  6. package/dist/ai-sdk.d.cts +3 -3
  7. package/dist/ai-sdk.d.ts +3 -3
  8. package/dist/ai-sdk.js +2 -2
  9. package/dist/anthropic.cjs +81 -0
  10. package/dist/anthropic.cjs.map +1 -1
  11. package/dist/anthropic.d.cts +3 -3
  12. package/dist/anthropic.d.ts +3 -3
  13. package/dist/anthropic.js +3 -3
  14. package/dist/bin.cjs +2 -0
  15. package/dist/bin.cjs.map +1 -1
  16. package/dist/bin.js +2 -0
  17. package/dist/bin.js.map +1 -1
  18. package/dist/{chunk-7MX4T5N4.js → chunk-NHVIUA4Z.js} +4 -4
  19. package/dist/{chunk-7MX4T5N4.js.map → chunk-NHVIUA4Z.js.map} +1 -1
  20. package/dist/{chunk-NHWU3RAY.js → chunk-OHF5SN7W.js} +3 -3
  21. package/dist/{chunk-NHWU3RAY.js.map → chunk-OHF5SN7W.js.map} +1 -1
  22. package/dist/{chunk-QK33F5WY.js → chunk-R6QMUCVR.js} +100 -3
  23. package/dist/chunk-R6QMUCVR.js.map +1 -0
  24. package/dist/index.cjs +100 -0
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +2 -2
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.js +1 -1
  29. package/dist/openai.cjs +81 -0
  30. package/dist/openai.cjs.map +1 -1
  31. package/dist/openai.d.cts +3 -3
  32. package/dist/openai.d.ts +3 -3
  33. package/dist/openai.js +3 -3
  34. package/dist/{stream-DUEP28_p.d.cts → stream-NO-TTGYs.d.cts} +87 -2
  35. package/dist/{stream-DUEP28_p.d.ts → stream-NO-TTGYs.d.ts} +87 -2
  36. package/package.json +1 -1
  37. package/dist/chunk-QK33F5WY.js.map +0 -1
package/README.md CHANGED
@@ -1,9 +1,15 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/edwinsatya/llm-output-guard/main/assets/logo.svg"
3
+ width="240" alt="llm-output-guard" />
4
+ </p>
5
+
1
6
  # llm-output-guard
2
7
 
3
8
  **Detect LLM responses that failed while returning `200 OK`.**
4
9
 
5
10
  [![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)
11
+ [![downloads](https://img.shields.io/npm/dm/llm-output-guard?color=0b7285)](https://www.npmjs.com/package/llm-output-guard)
12
+ [![minzipped](https://img.shields.io/bundlejs/size/llm-output-guard?color=0b7285&label=min%2Bgzip)](https://bundlejs.com/?q=llm-output-guard)
7
13
  [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://github.com/edwinsatya/llm-output-guard/blob/main/package.json)
8
14
  [![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
15
  [![license](https://img.shields.io/npm/l/llm-output-guard?color=0b7285)](./LICENSE)
@@ -12,7 +18,7 @@ Your retry layer watches for `429`, `5xx` and timeouts. It cannot see a model th
12
18
  looped until `max_tokens`, returned `{}`, stopped mid-sentence, or answered in the
13
19
  wrong language — because all of those arrive as a **successful request**.
14
20
 
15
- This produces the signal that layer is missing. Zero dependencies, ~3 KB gzipped,
21
+ This produces the signal that layer is missing. Zero dependencies, ~5 KB gzipped,
16
22
  synchronous, no network.
17
23
 
18
24
  ```bash
@@ -50,7 +56,8 @@ every detector, running on your own pasted output. No API key, no request.
50
56
  | `LOW_ENTROPY` | Character-level collapse, token artifacts | Compression ratio |
51
57
  | `TRUNCATED` | Cut off mid-thought | `finish_reason`, unbalanced fences |
52
58
  | `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) |
59
+ | `SCRIPT_MISMATCH` | Answered in the wrong alphabet | Share of letters outside the expected scripts (opt-in) |
60
+ | `LANG_MISMATCH` | Answered in the wrong language, same alphabet | Function-word profile (opt-in) |
54
61
 
55
62
  Every detector runs even after one fails, so a verdict shows the whole picture
56
63
  rather than whichever check happened to be ordered first. Each returns **0–1, not
@@ -141,6 +148,28 @@ See **[docs/detectors.md](docs/detectors.md)** for arrays of repeated records
141
148
  a JSON array of identical rows reads as a loop under the default scope, and
142
149
  `redundancyScope: 'jsonValues'` is the fix.
143
150
 
151
+ ### Answering in the wrong language
152
+
153
+ ```ts
154
+ checkOutput(raw, { ...presets.chat, expectScript: 'latin' });
155
+ ```
156
+
157
+ A model that ignores "answer in English" does not produce broken English, it
158
+ produces fluent Chinese. That is a `200 OK` your retry layer cannot see, and it
159
+ is detectable by counting characters — no word list, no model, decisive from
160
+ about a dozen letters. A response answered entirely in the wrong script scores
161
+ **1.000**; a healthy response measured against its own script scores
162
+ **0.000–0.028**.
163
+
164
+ Pass every script the answer may legitimately contain — `['han', 'latin']` for
165
+ Chinese, `['han', 'kana', 'latin']` for Japanese. `'latin'` belongs in nearly all
166
+ of them, because a Chinese answer about React still contains `useEffect`. Code
167
+ fences, inline code and URLs are removed before measuring, so a TypeScript block
168
+ never counts as answering in English.
169
+
170
+ Same script means no signal: Spanish against English scores 0. That is what
171
+ `expectLang` is for, and the two compose under separate codes.
172
+
144
173
  ## The verdict
145
174
 
146
175
  ```ts
@@ -191,7 +220,8 @@ because only one of those is evidence. Full guide:
191
220
  Korean, Cyrillic, Greek, Arabic and Devanagari separate words and are handled like
192
221
  English. **Chinese, Japanese and Thai do not**, so `TAIL_LOOP` switches to
193
222
  character mode and reads its own threshold. `REPETITION` is blind on those scripts —
194
- a known, measured gap, with the numbers behind it in
223
+ a known, measured gap. `SCRIPT_MISMATCH` covers all ten scripts and is the one
224
+ detector these are *not* the weak case for. Numbers behind both in
195
225
  **[docs/script-coverage.md](docs/script-coverage.md)**.
196
226
 
197
227
  ## Design notes
@@ -260,7 +290,8 @@ patches.
260
290
  - 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.
261
291
  - `openai`'s `responses.stream()` helper is not wrapped. See the note above; `create({ stream: true })` is.
262
292
  - `REPETITION` does not work on Chinese, Japanese or Thai. See above — this is a known, measured gap, not an oversight.
263
- - Language detection is a function-word heuristic covering `id`/`en`/`es`. Opt-in, and unreliable under 25 words.
293
+ - Language detection is a function-word heuristic covering `id`/`en`/`es`. Opt-in, and unreliable under 25 words. `expectScript` is the stronger check where the languages differ in alphabet, and says nothing where they do not.
294
+ - `SCRIPT_MISMATCH` does not run mid-stream. A mid-stream check reads a trailing window, and the language of a window is not the language of the response — an English answer quoting a Chinese passage measures 0.114 whole and 0.500 over its last 400 characters.
264
295
  - Truncation from a missing full stop is weak evidence, scored 0.55 and left below the default thresholds on purpose. Lower `maxTruncation` to ~0.5 to catch it, and expect false positives.
265
296
  - A JSON array of repeated identical records reads as a loop under the default scope, and fails from three records up. Set `redundancyScope: 'jsonValues'` — see **Structured output**.
266
297
  - Thresholds calibrated on the bundled corpus. Yours will differ — and the word and character thresholds need calibrating **separately**, because they are separate distributions.
@@ -1,4 +1,4 @@
1
- import { V as Verdict } from './stream-DUEP28_p.js';
1
+ import { V as Verdict } from './stream-NO-TTGYs.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-DUEP28_p.cjs';
1
+ import { V as Verdict } from './stream-NO-TTGYs.cjs';
2
2
 
3
3
  /**
4
4
  * The option surface every provider adapter shares.
package/dist/ai-sdk.cjs CHANGED
@@ -236,6 +236,52 @@ function languageMismatchScore(text, expected, options = {}) {
236
236
  return Math.min(1, (best - target) / best);
237
237
  }
238
238
 
239
+ // src/detectors/script.ts
240
+ var SCRIPTS = {
241
+ latin: /\p{Script=Latin}/u,
242
+ han: /\p{Script=Han}/u,
243
+ kana: /[\p{Script=Hiragana}\p{Script=Katakana}]/u,
244
+ hangul: /\p{Script=Hangul}/u,
245
+ cyrillic: /\p{Script=Cyrillic}/u,
246
+ arabic: /\p{Script=Arabic}/u,
247
+ devanagari: /\p{Script=Devanagari}/u,
248
+ greek: /\p{Script=Greek}/u,
249
+ hebrew: /\p{Script=Hebrew}/u,
250
+ thai: /\p{Script=Thai}/u
251
+ };
252
+ var LETTER_OR_MARK = /[\p{L}\p{M}]/gu;
253
+ var SCRIPT_NEUTRAL = /[\p{sc=Common}\p{sc=Inherited}]/u;
254
+ var FENCED_BLOCK = /```[\s\S]*?```/g;
255
+ var OPEN_FENCE_TO_END = /```[\s\S]*$/;
256
+ var INLINE_CODE = /`[^`\n]*`/g;
257
+ var URL = /\bhttps?:\/\/\S+/gi;
258
+ var stripNonLinguistic = (text) => text.replace(FENCED_BLOCK, " ").replace(OPEN_FENCE_TO_END, " ").replace(INLINE_CODE, " ").replace(URL, " ");
259
+ function scriptMismatchScore(text, expected, options = {}) {
260
+ const { minLetters = 12 } = options;
261
+ const wanted = Array.isArray(expected) ? expected : [expected];
262
+ const known = wanted.filter((name) => typeof name === "string" && Object.hasOwn(SCRIPTS, name));
263
+ if (known.length === 0) return 0;
264
+ const letters = judgeableLetters(text, options);
265
+ if (letters.length < minLetters) return 0;
266
+ let matched = 0;
267
+ for (const ch of letters) {
268
+ for (const name of known) {
269
+ if (SCRIPTS[name].test(ch)) {
270
+ matched++;
271
+ break;
272
+ }
273
+ }
274
+ }
275
+ return clamp01(1 - matched / letters.length);
276
+ }
277
+ function judgeableLetters(text, options) {
278
+ const { ignoreCode = true, maxSample = 8e3 } = options;
279
+ const source = ignoreCode ? stripNonLinguistic(text.slice(0, maxSample)) : text.slice(0, maxSample);
280
+ const found = source.match(LETTER_OR_MARK);
281
+ if (!found) return [];
282
+ return found.filter((ch) => !SCRIPT_NEUTRAL.test(ch));
283
+ }
284
+
239
285
  // src/internal/json-scope.ts
240
286
  function stringValues(value, out = []) {
241
287
  if (typeof value === "string") out.push(value);
@@ -269,6 +315,7 @@ var DEFAULTS = {
269
315
  expectJson: false,
270
316
  allowJsonFence: true,
271
317
  maxLangMismatch: 0.6,
318
+ maxScriptMismatch: 0.5,
272
319
  ngram: 3,
273
320
  redundancyScope: "document"
274
321
  };
@@ -366,6 +413,16 @@ function checkOutput(text, options = {}) {
366
413
  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."
367
414
  );
368
415
  }
416
+ if (opts.expectScript) {
417
+ const wanted = Array.isArray(opts.expectScript) ? opts.expectScript : [opts.expectScript];
418
+ const s = scriptMismatchScore(text, opts.expectScript);
419
+ add(
420
+ "SCRIPT_MISMATCH",
421
+ s,
422
+ opts.maxScriptMismatch,
423
+ `${Math.round(s * 100)}% of letters are not in ${wanted.join(" or ")}.`
424
+ );
425
+ }
369
426
  if (opts.expectLang) {
370
427
  const s = languageMismatchScore(text, opts.expectLang);
371
428
  add(
@@ -401,6 +458,30 @@ var DEFERRED_TO_END = {
401
458
  expectJson: false,
402
459
  expectLang: null,
403
460
  finishReason: void 0,
461
+ /*
462
+ * SCRIPT_MISMATCH is deferred for a reason none of the others share: it is
463
+ * the *window* that makes it unsafe, not the partial text.
464
+ *
465
+ * Which language a model answered in is a property of the whole response.
466
+ * Every mid-stream check reads the trailing `window` characters, so what it
467
+ * would actually measure is the language of the last few paragraphs -- and an
468
+ * English answer that ends by quoting a Chinese passage is a real thing that
469
+ * happens. Measured on exactly that shape: 0.114 across the document, 0.206
470
+ * over the last 1000 characters, and 0.500 over the last 400. The document is
471
+ * healthy and the window says it is half wrong, so a smaller `window` would
472
+ * buy earlier detection by manufacturing false positives out of quotations.
473
+ *
474
+ * The detector itself is decisive from about a dozen letters, so there is a
475
+ * real early-abort here for someone who wants it -- it just has to read the
476
+ * whole buffer rather than a window:
477
+ *
478
+ * const v = checkOutput(guard.text, { expectScript: 'latin' });
479
+ *
480
+ * Deliberately left to the caller. The guard cannot run one detector over a
481
+ * different span than the rest without becoming a per-detector span router,
482
+ * and this is one line for the callers who need it.
483
+ */
484
+ expectScript: null,
404
485
  /*
405
486
  * LOW_ENTROPY is deferred for a second reason: cost. The LZ77 pass is
406
487
  * 0.4ms at 500 characters and 11ms at its 4000-character sample cap, which