llm-output-guard 1.5.0 → 1.7.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 (42) hide show
  1. package/README.md +14 -7
  2. package/dist/{adapter-options-v9jM76u7.d.ts → adapter-options-B-fgYd--.d.cts} +15 -1
  3. package/dist/{adapter-options-h2gysBrj.d.cts → adapter-options-DNkMNS92.d.ts} +15 -1
  4. package/dist/ai-sdk.cjs +98 -11
  5. package/dist/ai-sdk.cjs.map +1 -1
  6. package/dist/ai-sdk.d.cts +14 -5
  7. package/dist/ai-sdk.d.ts +14 -5
  8. package/dist/ai-sdk.js +24 -10
  9. package/dist/ai-sdk.js.map +1 -1
  10. package/dist/anthropic.cjs +114 -13
  11. package/dist/anthropic.cjs.map +1 -1
  12. package/dist/anthropic.d.cts +3 -3
  13. package/dist/anthropic.d.ts +3 -3
  14. package/dist/anthropic.js +11 -3
  15. package/dist/anthropic.js.map +1 -1
  16. package/dist/bin.cjs +680 -0
  17. package/dist/bin.cjs.map +1 -1
  18. package/dist/bin.js +680 -0
  19. package/dist/bin.js.map +1 -1
  20. package/dist/{chunk-JYDJYJEY.js → chunk-6XVGQCC2.js} +38 -4
  21. package/dist/chunk-6XVGQCC2.js.map +1 -0
  22. package/dist/{chunk-RDIQ3HKR.js → chunk-CKOCNNFY.js} +51 -5
  23. package/dist/chunk-CKOCNNFY.js.map +1 -0
  24. package/dist/{chunk-PQXX7OEN.js → chunk-XJDWUHOP.js} +27 -14
  25. package/dist/chunk-XJDWUHOP.js.map +1 -0
  26. package/dist/index.cjs +49 -3
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +2 -2
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.js +1 -1
  31. package/dist/openai.cjs +116 -13
  32. package/dist/openai.cjs.map +1 -1
  33. package/dist/openai.d.cts +3 -3
  34. package/dist/openai.d.ts +3 -3
  35. package/dist/openai.js +13 -3
  36. package/dist/openai.js.map +1 -1
  37. package/dist/{stream-BSCIXfIH.d.cts → stream-BYy6vuGy.d.cts} +38 -2
  38. package/dist/{stream-BSCIXfIH.d.ts → stream-BYy6vuGy.d.ts} +38 -2
  39. package/package.json +1 -1
  40. package/dist/chunk-JYDJYJEY.js.map +0 -1
  41. package/dist/chunk-PQXX7OEN.js.map +0 -1
  42. package/dist/chunk-RDIQ3HKR.js.map +0 -1
package/README.md CHANGED
@@ -95,8 +95,10 @@ Adapters for the **OpenAI SDK** (both `chat.completions` and `responses`),
95
95
  protocol: Groq, Together, OpenRouter, Fireworks, vLLM, Ollama.
96
96
 
97
97
  On a stream this **cancels the HTTP request** the moment a loop is detectable, so
98
- you stop paying for the rest of it. Running agents? Add `checkToolArguments: true`
99
- to measure the arguments as well as the prose beside them.
98
+ you stop paying for the rest of it. Two switches worth knowing:
99
+ `checkToolArguments: true` measures the arguments a model passes to a tool, and
100
+ `checkPromptEcho: true` reads the prompt out of each request so a model that
101
+ replays it instead of answering is caught.
100
102
 
101
103
  **[docs/adapters.md](docs/adapters.md)** · **[docs/streaming.md](docs/streaming.md)**
102
104
 
@@ -154,6 +156,8 @@ length:
154
156
 
155
157
  ```ts
156
158
  checkOutput(raw, { ...presets.chat, prompt });
159
+ // or, from an adapter, which reads the prompt out of the request itself:
160
+ withOutputGuard(new OpenAI(), { ...presets.chat, checkPromptEcho: true });
157
161
  ```
158
162
 
159
163
  Not for rewrite, translate or summarise endpoints, where copying the input is
@@ -192,13 +196,16 @@ truths.
192
196
 
193
197
  ## Calibrate against your own traffic
194
198
 
195
- The shipped presets are tuned on a corpus that is **not your traffic**. Log your
196
- scores for a week, then derive thresholds you can defend:
199
+ The shipped presets are tuned on a corpus that is **not your traffic**. Score the
200
+ responses you already have, then derive thresholds you can defend:
197
201
 
198
202
  ```bash
199
- npx llm-output-guard calibrate scores.jsonl --fpr 0.001
203
+ npx llm-output-guard check logs/*.txt --json | npx llm-output-guard calibrate --fpr 0.001
200
204
  ```
201
205
 
206
+ `check` also works as a CI assertion — it exits 1 when anything is degenerate,
207
+ 2 when the input cannot be read.
208
+
202
209
  The report tells you when your sample is too small to support the rate you asked
203
210
  for, and distinguishes real separation in your data from a false-positive budget —
204
211
  because only one of those is evidence.
@@ -261,9 +268,9 @@ re-released as 0.5.0. The rule it broke is the one in the table above.
261
268
 
262
269
  - Not a hallucination detector. It measures *shape*, never truth.
263
270
  - `REPETITION` does not work on Chinese, Japanese or Thai — a known, measured gap, not an oversight.
264
- - `LANG_MISMATCH` is a function-word heuristic covering `id`/`en`/`es`, and is unreliable under 25 words. `expectScript` is the stronger check wherever the alphabets differ.
271
+ - `LANG_MISMATCH` is a function-word heuristic covering `id`/`en`/`es`/`pt`/`it`/`fr`/`de`/`nl`, and is unreliable under 25 words. `expectScript` is the stronger check wherever the alphabets differ.
265
272
  - `PROMPT_ECHO` cannot tell a degenerate echo from a rewrite or translation — the difference is in what you asked for, not in the text.
266
- - `SCRIPT_MISMATCH` and `PROMPT_ECHO` do not run mid-stream: both measure a property of the whole response, and a mid-stream check reads a trailing window.
273
+ - `SCRIPT_MISMATCH` and `PROMPT_ECHO` do not run mid-stream by default: both measure a property of the whole response, and a mid-stream check reads a trailing window. `earlyDocumentChecks: true` opts in, with a measured false-positive risk — see **[docs/streaming.md](docs/streaming.md)**.
267
274
  - Tool *arguments* are measured only with `checkToolArguments: true`, non-streaming responses only.
268
275
  - `openai`'s `responses.stream()` helper is not wrapped; `create({ stream: true })` is.
269
276
  - Truncation from a missing full stop is weak evidence, scored 0.55 and deliberately left below the defaults. Lower `maxTruncation` to ~0.5 to catch it, and expect false positives.
@@ -1,4 +1,4 @@
1
- import { V as Verdict } from './stream-BSCIXfIH.js';
1
+ import { V as Verdict } from './stream-BYy6vuGy.cjs';
2
2
 
3
3
  /**
4
4
  * The option surface every provider adapter shares.
@@ -82,6 +82,20 @@ interface AdapterGuardOptions {
82
82
  * measure mid-stream.
83
83
  */
84
84
  checkToolArguments?: boolean;
85
+ /**
86
+ * Check the response against the prompt that produced it, so a model that
87
+ * replays your prompt instead of answering is caught. Default `false`.
88
+ *
89
+ * `PROMPT_ECHO` needs the prompt, and a guard is configured once when you
90
+ * wrap the client while the prompt changes on every call -- so this is a
91
+ * switch rather than a value. Turn it on and the adapter reads the prompt out
92
+ * of each request it is already forwarding.
93
+ *
94
+ * **Do not enable this on a rewrite, translate, summarise or extract
95
+ * endpoint.** Copying from the input is the job on those, so a correct answer
96
+ * scores high and the detector measures the task rather than a failure.
97
+ */
98
+ checkPromptEcho?: boolean;
85
99
  }
86
100
 
87
101
  export type { AdapterGuardOptions as A, DegenerateAction as D };
@@ -1,4 +1,4 @@
1
- import { V as Verdict } from './stream-BSCIXfIH.cjs';
1
+ import { V as Verdict } from './stream-BYy6vuGy.js';
2
2
 
3
3
  /**
4
4
  * The option surface every provider adapter shares.
@@ -82,6 +82,20 @@ interface AdapterGuardOptions {
82
82
  * measure mid-stream.
83
83
  */
84
84
  checkToolArguments?: boolean;
85
+ /**
86
+ * Check the response against the prompt that produced it, so a model that
87
+ * replays your prompt instead of answering is caught. Default `false`.
88
+ *
89
+ * `PROMPT_ECHO` needs the prompt, and a guard is configured once when you
90
+ * wrap the client while the prompt changes on every call -- so this is a
91
+ * switch rather than a value. Turn it on and the adapter reads the prompt out
92
+ * of each request it is already forwarding.
93
+ *
94
+ * **Do not enable this on a rewrite, translate, summarise or extract
95
+ * endpoint.** Copying from the input is the job on those, so a correct answer
96
+ * scores high and the detector measures the task rather than a failure.
97
+ */
98
+ checkPromptEcho?: boolean;
85
99
  }
86
100
 
87
101
  export type { AdapterGuardOptions as A, DegenerateAction as D };
package/dist/ai-sdk.cjs CHANGED
@@ -210,7 +210,12 @@ function jsonScore(text, options = {}) {
210
210
  var PROFILES = {
211
211
  id: /* @__PURE__ */ new Set(["yang", "dan", "di", "untuk", "dengan", "ini", "itu", "dari", "pada", "tidak", "adalah", "akan", "bisa", "kita", "saya", "atau", "juga", "dalam", "sudah", "ke"]),
212
212
  en: /* @__PURE__ */ new Set(["the", "and", "of", "to", "in", "is", "that", "for", "it", "with", "as", "this", "are", "be", "you", "on", "not", "or", "can", "we"]),
213
- es: /* @__PURE__ */ new Set(["el", "la", "de", "que", "y", "en", "los", "un", "por", "con", "las", "para", "una", "es", "no", "se", "del", "al", "lo", "como"])
213
+ es: /* @__PURE__ */ new Set(["el", "los", "las", "y", "es", "no", "muy", "pero", "este", "esta", "sus", "cuando", "donde", "porque", "sin", "hasta", "aunque", "mismo", "otro", "todos"]),
214
+ pt: /* @__PURE__ */ new Set(["n\xE3o", "\xE9", "s\xE3o", "uma", "com", "em", "do", "da", "dos", "das", "ao", "voc\xEA", "tamb\xE9m", "muito", "mais", "j\xE1", "pelo", "isso", "seu", "sua"]),
215
+ it: /* @__PURE__ */ new Set(["il", "di", "che", "non", "per", "della", "nel", "nella", "gli", "pi\xF9", "anche", "perch\xE9", "questo", "sono", "essere", "dei", "delle", "agli", "per\xF2", "sia"]),
216
+ fr: /* @__PURE__ */ new Set(["les", "des", "du", "qui", "dans", "pour", "sur", "pas", "ce", "est", "plus", "nous", "vous", "avec", "cette", "aux", "\xEAtre", "mais", "leur", "tout"]),
217
+ de: /* @__PURE__ */ new Set(["der", "die", "das", "und", "ist", "nicht", "ein", "eine", "sich", "mit", "den", "f\xFCr", "auf", "von", "dem", "werden", "oder", "auch", "wird", "kann"]),
218
+ nl: /* @__PURE__ */ new Set(["het", "een", "niet", "zijn", "worden", "maar", "deze", "wordt", "ook", "naar", "bij", "over", "door", "tussen", "omdat", "moet", "kan", "heeft", "dat", "van"])
214
219
  };
215
220
  function languageProfile(text) {
216
221
  const w = words(text);
@@ -570,8 +575,44 @@ var DEFERRED_TO_END = {
570
575
  */
571
576
  maxCompressibility: null
572
577
  };
578
+ var EARLY_DOCUMENT_MIN = 600;
579
+ var EARLY_DOCUMENT_CERTAINTY = 0.9;
580
+ var EARLY_DOCUMENT_SAMPLE = 8e3;
581
+ var DOCUMENT_ONLY = {
582
+ minLength: 0,
583
+ maxRepetition: null,
584
+ maxTailLoop: null,
585
+ maxCharTailLoop: null,
586
+ maxCompressibility: null,
587
+ maxTruncation: null,
588
+ expectJson: false,
589
+ expectLang: null,
590
+ finishReason: void 0,
591
+ maxScriptMismatch: EARLY_DOCUMENT_CERTAINTY,
592
+ maxPromptEcho: EARLY_DOCUMENT_CERTAINTY
593
+ };
594
+ function mergeEarly(windowVerdict, document) {
595
+ if (!document) return windowVerdict;
596
+ const reasons = [...windowVerdict.reasons, ...document.reasons];
597
+ return {
598
+ ...windowVerdict,
599
+ ok: reasons.length === 0,
600
+ reasons,
601
+ scores: { ...windowVerdict.scores, ...document.scores },
602
+ ...document.modes || windowVerdict.modes ? { modes: { ...windowVerdict.modes, ...document.modes } } : {}
603
+ };
604
+ }
573
605
  function createStreamGuard(options = {}) {
574
- const { checkEvery = 400, warmup = 240, window = 2e3, ...checkOptions } = options;
606
+ const {
607
+ checkEvery = 400,
608
+ warmup = 240,
609
+ window = 2e3,
610
+ earlyDocumentChecks = false,
611
+ ...checkOptions
612
+ } = options;
613
+ const earlyDocument = earlyDocumentChecks && Boolean(checkOptions.expectScript || checkOptions.prompt);
614
+ let nextDocumentAt = EARLY_DOCUMENT_MIN;
615
+ let documentSettled = false;
575
616
  let text = "";
576
617
  let sinceCheck = 0;
577
618
  let checks = 0;
@@ -591,7 +632,12 @@ function createStreamGuard(options = {}) {
591
632
  sinceCheck = 0;
592
633
  checks += 1;
593
634
  const recent = text.length > window ? text.slice(-window) : text;
594
- return checkOutput(recent, { ...checkOptions, ...DEFERRED_TO_END });
635
+ const verdict = checkOutput(recent, { ...checkOptions, ...DEFERRED_TO_END });
636
+ if (!earlyDocument || documentSettled || text.length < nextDocumentAt) return verdict;
637
+ nextDocumentAt = text.length * 2;
638
+ const head = text.length > EARLY_DOCUMENT_SAMPLE ? text.slice(0, EARLY_DOCUMENT_SAMPLE) : text;
639
+ if (head.length >= EARLY_DOCUMENT_SAMPLE) documentSettled = true;
640
+ return mergeEarly(verdict, checkOutput(head, { ...checkOptions, ...DOCUMENT_ONLY }));
595
641
  },
596
642
  end(finishReason) {
597
643
  return checkOutput(text, {
@@ -668,6 +714,33 @@ function mergeVerdicts(preamble, args) {
668
714
  };
669
715
  }
670
716
 
717
+ // src/internal/prompt-text.ts
718
+ var INPUT_ROLES = /* @__PURE__ */ new Set(["system", "developer", "user", "human"]);
719
+ function contentText(content) {
720
+ if (typeof content === "string") return content;
721
+ if (!Array.isArray(content)) return "";
722
+ return content.map((part) => {
723
+ if (typeof part === "string") return part;
724
+ if (part && typeof part === "object") {
725
+ const text = part.text;
726
+ if (typeof text === "string") return text;
727
+ }
728
+ return "";
729
+ }).filter(Boolean).join("\n");
730
+ }
731
+ function promptFromMessages(messages) {
732
+ if (!Array.isArray(messages)) return "";
733
+ const parts = [];
734
+ for (const message of messages) {
735
+ if (!message || typeof message !== "object") continue;
736
+ const role = message.role;
737
+ if (typeof role === "string" && !INPUT_ROLES.has(role)) continue;
738
+ const text = contentText(message.content);
739
+ if (text.trim().length > 0) parts.push(text);
740
+ }
741
+ return parts.join("\n\n");
742
+ }
743
+
671
744
  // src/ai-sdk.ts
672
745
  var isToolPart = (part) => part.type.startsWith("tool-");
673
746
  function finishReasonOf(value) {
@@ -676,7 +749,18 @@ function finishReasonOf(value) {
676
749
  return void 0;
677
750
  }
678
751
  function outputGuard(options = {}) {
679
- const { onDegenerate = "throw", onVerdict, checkToolArguments = false, ...guardOptions } = options;
752
+ const {
753
+ onDegenerate = "throw",
754
+ onVerdict,
755
+ checkToolArguments = false,
756
+ checkPromptEcho = false,
757
+ ...guardOptions
758
+ } = options;
759
+ const optionsFor = (params) => {
760
+ if (!checkPromptEcho || guardOptions.prompt) return guardOptions;
761
+ const prompt = promptFromMessages(params?.prompt);
762
+ return prompt ? { ...guardOptions, prompt } : guardOptions;
763
+ };
680
764
  const act = (verdict, streaming) => {
681
765
  onVerdict?.(verdict, { streaming });
682
766
  if (verdict.ok || onDegenerate === "ignore") return;
@@ -704,16 +788,18 @@ function outputGuard(options = {}) {
704
788
  * all it can do is stop a bad answer from being used as a good one.
705
789
  */
706
790
  async wrapGenerate({
707
- doGenerate
791
+ doGenerate,
792
+ params
708
793
  }) {
709
794
  const result = await doGenerate();
795
+ const callOptions = optionsFor(params);
710
796
  const content = result.content ?? [];
711
797
  const text = content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("");
712
798
  if (content.some(isToolPart)) {
713
- const preamble = checkPreamble(text, guardOptions);
799
+ const preamble = checkPreamble(text, callOptions);
714
800
  const args = checkToolArguments ? checkArguments(
715
801
  content.filter((part) => part.type === "tool-call").map((part) => part.input ?? part.args),
716
- guardOptions
802
+ callOptions
717
803
  ) : null;
718
804
  const verdict = mergeVerdicts(preamble, args);
719
805
  if (verdict) act(verdict, false);
@@ -721,18 +807,19 @@ function outputGuard(options = {}) {
721
807
  }
722
808
  act(
723
809
  checkOutput(text, {
724
- ...guardOptions,
725
- finishReason: finishReasonOf(result.finishReason) ?? guardOptions.finishReason
810
+ ...callOptions,
811
+ finishReason: finishReasonOf(result.finishReason) ?? callOptions.finishReason
726
812
  }),
727
813
  false
728
814
  );
729
815
  return result;
730
816
  },
731
817
  async wrapStream({
732
- doStream
818
+ doStream,
819
+ params
733
820
  }) {
734
821
  const result = await doStream();
735
- const guard = createStreamGuard(guardOptions);
822
+ const guard = createStreamGuard(optionsFor(params));
736
823
  let fired = false;
737
824
  let sawToolCall = false;
738
825
  let finishReason;