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.
- package/README.md +14 -7
- package/dist/{adapter-options-v9jM76u7.d.ts → adapter-options-B-fgYd--.d.cts} +15 -1
- package/dist/{adapter-options-h2gysBrj.d.cts → adapter-options-DNkMNS92.d.ts} +15 -1
- package/dist/ai-sdk.cjs +98 -11
- package/dist/ai-sdk.cjs.map +1 -1
- package/dist/ai-sdk.d.cts +14 -5
- package/dist/ai-sdk.d.ts +14 -5
- package/dist/ai-sdk.js +24 -10
- package/dist/ai-sdk.js.map +1 -1
- package/dist/anthropic.cjs +114 -13
- package/dist/anthropic.cjs.map +1 -1
- package/dist/anthropic.d.cts +3 -3
- package/dist/anthropic.d.ts +3 -3
- package/dist/anthropic.js +11 -3
- package/dist/anthropic.js.map +1 -1
- package/dist/bin.cjs +680 -0
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +680 -0
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-JYDJYJEY.js → chunk-6XVGQCC2.js} +38 -4
- package/dist/chunk-6XVGQCC2.js.map +1 -0
- package/dist/{chunk-RDIQ3HKR.js → chunk-CKOCNNFY.js} +51 -5
- package/dist/chunk-CKOCNNFY.js.map +1 -0
- package/dist/{chunk-PQXX7OEN.js → chunk-XJDWUHOP.js} +27 -14
- package/dist/chunk-XJDWUHOP.js.map +1 -0
- package/dist/index.cjs +49 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/openai.cjs +116 -13
- package/dist/openai.cjs.map +1 -1
- package/dist/openai.d.cts +3 -3
- package/dist/openai.d.ts +3 -3
- package/dist/openai.js +13 -3
- package/dist/openai.js.map +1 -1
- package/dist/{stream-BSCIXfIH.d.cts → stream-BYy6vuGy.d.cts} +38 -2
- package/dist/{stream-BSCIXfIH.d.ts → stream-BYy6vuGy.d.ts} +38 -2
- package/package.json +1 -1
- package/dist/chunk-JYDJYJEY.js.map +0 -1
- package/dist/chunk-PQXX7OEN.js.map +0 -1
- 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.
|
|
99
|
-
|
|
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**.
|
|
196
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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", "
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
...
|
|
725
|
-
finishReason: finishReasonOf(result.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(
|
|
822
|
+
const guard = createStreamGuard(optionsFor(params));
|
|
736
823
|
let fired = false;
|
|
737
824
|
let sawToolCall = false;
|
|
738
825
|
let finishReason;
|