llm-output-guard 0.1.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/dist/index.js ADDED
@@ -0,0 +1,327 @@
1
+ // src/internal/tokenize.ts
2
+ function words(text) {
3
+ return text.toLowerCase().match(/[\p{L}\p{N}']+/gu) ?? [];
4
+ }
5
+ function clamp01(n) {
6
+ if (Number.isNaN(n)) return 0;
7
+ return n < 0 ? 0 : n > 1 ? 1 : n;
8
+ }
9
+ function excerpt(text, max = 80) {
10
+ const flat = text.replace(/\s+/g, " ").trim();
11
+ return flat.length <= max ? flat : flat.slice(0, max) + "\u2026";
12
+ }
13
+
14
+ // src/detectors/repetition.ts
15
+ function repetitionScore(text, options = {}) {
16
+ const { n = 3, maxSample = 8e3 } = options;
17
+ const w = words(text.slice(0, maxSample));
18
+ if (w.length < n * 4) return 0;
19
+ const seen = /* @__PURE__ */ new Set();
20
+ let total = 0;
21
+ for (let i = 0; i + n <= w.length; i++) {
22
+ seen.add(w.slice(i, i + n).join(" "));
23
+ total++;
24
+ }
25
+ if (total === 0) return 0;
26
+ return clamp01(1 - seen.size / total);
27
+ }
28
+ function tailLoopScore(text, options = {}) {
29
+ const { tailWords = 200, maxPeriod = 40, minRepeats = 3 } = options;
30
+ const all = words(text);
31
+ const tail = all.slice(-tailWords);
32
+ if (tail.length < minRepeats * 2) return 0;
33
+ let best = 0;
34
+ const periodCap = Math.min(maxPeriod, Math.floor(tail.length / minRepeats));
35
+ for (let p = 1; p <= periodCap; p++) {
36
+ const block = tail.slice(tail.length - p);
37
+ let repeats = 1;
38
+ let cursor = tail.length - p;
39
+ while (cursor - p >= 0) {
40
+ let same = true;
41
+ for (let k = 0; k < p; k++) {
42
+ if (tail[cursor - p + k] !== block[k]) {
43
+ same = false;
44
+ break;
45
+ }
46
+ }
47
+ if (!same) break;
48
+ repeats++;
49
+ cursor -= p;
50
+ }
51
+ if (repeats >= minRepeats) {
52
+ best = Math.max(best, clamp01(repeats * p / tail.length));
53
+ }
54
+ }
55
+ return best;
56
+ }
57
+
58
+ // src/detectors/compressibility.ts
59
+ function compressionRatio(text, options = {}) {
60
+ const { window = 1024, maxSample = 4e3, minMatch = 4 } = options;
61
+ const s = text.slice(0, maxSample);
62
+ if (s.length < 64) return 1;
63
+ let i = 0;
64
+ let emitted = 0;
65
+ while (i < s.length) {
66
+ let bestLen = 0;
67
+ const start = i > window ? i - window : 0;
68
+ for (let j = start; j < i; j++) {
69
+ let k = 0;
70
+ while (k < 255 && i + k < s.length && s[j + k] === s[i + k]) k++;
71
+ if (k > bestLen) {
72
+ bestLen = k;
73
+ if (bestLen >= 255) break;
74
+ }
75
+ }
76
+ emitted++;
77
+ i += bestLen >= minMatch ? bestLen : 1;
78
+ }
79
+ return emitted / s.length;
80
+ }
81
+ function compressibilityScore(text, options = {}) {
82
+ const { pivot = 0.32, ...rest } = options;
83
+ if (text.trim().length < 64) return 0;
84
+ return clamp01(1 - compressionRatio(text, rest) / pivot);
85
+ }
86
+
87
+ // src/detectors/emptiness.ts
88
+ function emptinessScore(text) {
89
+ const trimmed = text.trim();
90
+ if (trimmed.length === 0) return 1;
91
+ if (words(trimmed).length === 0) return 1;
92
+ const stripped = trimmed.replace(/```[a-z]*\s*```/gi, "").replace(/^[{}[\]"'\s,.:;!?-]+$/g, "");
93
+ return stripped.trim().length === 0 ? 1 : 0;
94
+ }
95
+ function shortnessScore(text, minChars) {
96
+ if (minChars <= 0) return 0;
97
+ const len = text.trim().length;
98
+ if (len >= minChars) return 0;
99
+ return 1 - len / minChars;
100
+ }
101
+
102
+ // src/detectors/truncation.ts
103
+ var LENGTH_STOPS = /* @__PURE__ */ new Set(["length", "max_tokens", "maxtokens", "max_output_tokens", "token_limit"]);
104
+ var TERMINAL = /[.!?"'`\u2019\u201d)\]}:;\u3002\uff01\uff1f]\s*$/;
105
+ function truncationScore(text, options = {}) {
106
+ const { finishReason } = options;
107
+ if (finishReason && LENGTH_STOPS.has(finishReason.toLowerCase())) return 1;
108
+ const trimmed = text.trim();
109
+ if (trimmed.length === 0) return 0;
110
+ let score = 0;
111
+ const fences = (trimmed.match(/```/g) ?? []).length;
112
+ if (fences % 2 === 1) score = Math.max(score, 0.9);
113
+ for (const [open, close] of [["{", "}"], ["[", "]"], ["(", ")"]]) {
114
+ const opens = trimmed.split(open).length - 1;
115
+ const closes = trimmed.split(close).length - 1;
116
+ if (opens > closes) score = Math.max(score, 0.8);
117
+ }
118
+ if (!TERMINAL.test(trimmed)) score = Math.max(score, 0.55);
119
+ return score;
120
+ }
121
+
122
+ // src/detectors/json.ts
123
+ function stripFence(text) {
124
+ const fenced = text.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
125
+ return fenced ? fenced[1] : text.trim();
126
+ }
127
+ function jsonScore(text, options = {}) {
128
+ const { allowFence = true, requiredKeys = [] } = options;
129
+ const candidate = allowFence ? stripFence(text) : text.trim();
130
+ let value;
131
+ try {
132
+ value = JSON.parse(candidate);
133
+ } catch {
134
+ return { score: 1, reason: "unparseable" };
135
+ }
136
+ if (requiredKeys.length > 0) {
137
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
138
+ return { score: 1, value, reason: "missing-keys", missingKeys: [...requiredKeys] };
139
+ }
140
+ const record = value;
141
+ const missing = requiredKeys.filter((k) => !(k in record));
142
+ if (missing.length > 0) {
143
+ return { score: 1, value, reason: "missing-keys", missingKeys: missing };
144
+ }
145
+ }
146
+ return { score: 0, value };
147
+ }
148
+
149
+ // src/detectors/language.ts
150
+ var PROFILES = {
151
+ id: /* @__PURE__ */ new Set(["yang", "dan", "di", "untuk", "dengan", "ini", "itu", "dari", "pada", "tidak", "adalah", "akan", "bisa", "kita", "saya", "atau", "juga", "dalam", "sudah", "ke"]),
152
+ en: /* @__PURE__ */ new Set(["the", "and", "of", "to", "in", "is", "that", "for", "it", "with", "as", "this", "are", "be", "you", "on", "not", "or", "can", "we"]),
153
+ es: /* @__PURE__ */ new Set(["el", "la", "de", "que", "y", "en", "los", "un", "por", "con", "las", "para", "una", "es", "no", "se", "del", "al", "lo", "como"])
154
+ };
155
+ function languageProfile(text) {
156
+ const w = words(text);
157
+ const out = {};
158
+ if (w.length === 0) return out;
159
+ for (const [lang, set] of Object.entries(PROFILES)) {
160
+ let hits = 0;
161
+ for (const token of w) if (set.has(token)) hits++;
162
+ out[lang] = hits / w.length;
163
+ }
164
+ return out;
165
+ }
166
+ function languageMismatchScore(text, expected, options = {}) {
167
+ const { minWords = 25 } = options;
168
+ if (!(expected in PROFILES)) return 0;
169
+ const w = words(text);
170
+ if (w.length < minWords) return 0;
171
+ const profile = languageProfile(text);
172
+ const target = profile[expected] ?? 0;
173
+ const best = Math.max(...Object.values(profile));
174
+ if (best === 0) return 0;
175
+ if (target >= best) return 0;
176
+ return Math.min(1, (best - target) / best);
177
+ }
178
+ var supportedLanguages = Object.keys(PROFILES);
179
+
180
+ // src/check.ts
181
+ var DEFAULTS = {
182
+ minLength: 1,
183
+ maxRepetition: 0.35,
184
+ maxTailLoop: 0.5,
185
+ maxCompressibility: 0.75,
186
+ maxTruncation: null,
187
+ expectJson: false,
188
+ allowJsonFence: true,
189
+ maxLangMismatch: 0.6,
190
+ ngram: 3
191
+ };
192
+ function checkOutput(text, options = {}) {
193
+ const opts = { ...DEFAULTS, ...options };
194
+ const reasons = [];
195
+ const scores = {};
196
+ let parsedJson;
197
+ const add = (code, score, threshold, message) => {
198
+ scores[code] = score;
199
+ if (score > threshold) reasons.push({ code, score, threshold, message });
200
+ };
201
+ const empty = emptinessScore(text);
202
+ add("EMPTY", empty, 0.5, "Response contains no usable content.");
203
+ if (empty >= 1) {
204
+ return { ok: false, reasons, scores };
205
+ }
206
+ if (opts.minLength > 0) {
207
+ add(
208
+ "TOO_SHORT",
209
+ shortnessScore(text, opts.minLength),
210
+ 0,
211
+ `Response is ${text.trim().length} chars, below the ${opts.minLength} minimum.`
212
+ );
213
+ }
214
+ if (opts.maxRepetition != null) {
215
+ const s = repetitionScore(text, { n: opts.ngram });
216
+ add(
217
+ "REPETITION",
218
+ s,
219
+ opts.maxRepetition,
220
+ `${Math.round(s * 100)}% of ${opts.ngram}-grams are duplicates.`
221
+ );
222
+ }
223
+ if (opts.maxTailLoop != null) {
224
+ const s = tailLoopScore(text);
225
+ add(
226
+ "TAIL_LOOP",
227
+ s,
228
+ opts.maxTailLoop,
229
+ `Response ends in a repeating block covering ${Math.round(s * 100)}% of the tail.`
230
+ );
231
+ }
232
+ if (opts.maxCompressibility != null) {
233
+ const s = compressibilityScore(text);
234
+ add(
235
+ "LOW_ENTROPY",
236
+ s,
237
+ opts.maxCompressibility,
238
+ "Response is far more compressible than natural language."
239
+ );
240
+ }
241
+ if (opts.maxTruncation != null || opts.finishReason) {
242
+ const s = truncationScore(text, { finishReason: opts.finishReason });
243
+ add(
244
+ "TRUNCATED",
245
+ s,
246
+ opts.maxTruncation ?? 0.75,
247
+ `Response appears cut off near: "${excerpt(text.trim().slice(-60), 60)}"`
248
+ );
249
+ }
250
+ if (opts.expectJson) {
251
+ const result = jsonScore(text, {
252
+ allowFence: opts.allowJsonFence,
253
+ requiredKeys: opts.requiredKeys
254
+ });
255
+ parsedJson = result.value;
256
+ add(
257
+ "INVALID_JSON",
258
+ result.score,
259
+ 0,
260
+ result.reason === "missing-keys" ? `JSON is missing required keys: ${result.missingKeys?.join(", ")}.` : "Response is not parseable JSON."
261
+ );
262
+ }
263
+ if (opts.expectLang) {
264
+ const s = languageMismatchScore(text, opts.expectLang);
265
+ add(
266
+ "LANG_MISMATCH",
267
+ s,
268
+ opts.maxLangMismatch,
269
+ `Response does not look like '${opts.expectLang}'.`
270
+ );
271
+ }
272
+ return { ok: reasons.length === 0, reasons, scores, json: parsedJson };
273
+ }
274
+ var DegenerateOutputError = class extends Error {
275
+ verdict;
276
+ /** Marks this as safe to retry against another provider. */
277
+ retryable = true;
278
+ constructor(verdict) {
279
+ super(`Degenerate LLM output: ${verdict.reasons.map((r) => r.code).join(", ")}`);
280
+ this.name = "DegenerateOutputError";
281
+ this.verdict = verdict;
282
+ }
283
+ };
284
+ function assertOutput(text, options = {}) {
285
+ const verdict = checkOutput(text, options);
286
+ if (!verdict.ok) throw new DegenerateOutputError(verdict);
287
+ return text;
288
+ }
289
+
290
+ // src/presets.ts
291
+ var presets = {
292
+ /** Conversational replies. Tolerant of quoted or listed repetition. */
293
+ chat: {
294
+ minLength: 12,
295
+ maxRepetition: 0.4,
296
+ maxTailLoop: 0.5,
297
+ maxCompressibility: 0.75
298
+ },
299
+ /** Structured output. Parseability is non-negotiable; prose checks relax. */
300
+ strictJson: {
301
+ minLength: 2,
302
+ expectJson: true,
303
+ maxRepetition: 0.6,
304
+ maxTailLoop: 0.6,
305
+ maxCompressibility: null,
306
+ maxTruncation: 0.75
307
+ },
308
+ /** Long-form generation, where truncation matters most. */
309
+ longForm: {
310
+ minLength: 200,
311
+ maxRepetition: 0.35,
312
+ maxTailLoop: 0.4,
313
+ maxCompressibility: 0.7,
314
+ maxTruncation: 0.75
315
+ },
316
+ /** Catches only unambiguous garbage. Use when false positives cost more than misses. */
317
+ lenient: {
318
+ minLength: 1,
319
+ maxRepetition: 0.7,
320
+ maxTailLoop: 0.7,
321
+ maxCompressibility: 0.9
322
+ }
323
+ };
324
+
325
+ export { DegenerateOutputError, assertOutput, checkOutput, compressibilityScore, compressionRatio, emptinessScore, jsonScore, languageMismatchScore, languageProfile, presets, repetitionScore, shortnessScore, stripFence, supportedLanguages, tailLoopScore, truncationScore };
326
+ //# sourceMappingURL=index.js.map
327
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/internal/tokenize.ts","../src/detectors/repetition.ts","../src/detectors/compressibility.ts","../src/detectors/emptiness.ts","../src/detectors/truncation.ts","../src/detectors/json.ts","../src/detectors/language.ts","../src/check.ts","../src/presets.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/** Word tokenizer that works across scripts (Latin, Cyrillic, and friends). */\nexport function words(text: string): string[] {\n return text.toLowerCase().match(/[\\p{L}\\p{N}']+/gu) ?? [];\n}\n\n/** Clamp a raw signal into the 0..1 suspicion range. */\nexport function clamp01(n: number): number {\n if (Number.isNaN(n)) return 0;\n return n < 0 ? 0 : n > 1 ? 1 : n;\n}\n\n/** Short, safe excerpt for messages. Never leaks a full response into logs. */\nexport function excerpt(text: string, max = 80): string {\n const flat = text.replace(/\\s+/g, ' ').trim();\n return flat.length <= max ? flat : flat.slice(0, max) + '\\u2026';\n}\n","import { words, clamp01 } from '../internal/tokenize.js';\n\nexport interface RepetitionOptions {\n /** N-gram size. 3 suits prose; 2 is noisy, 4 misses short loops. */\n n?: number;\n /** Only analyse the first N characters. Keeps cost bounded on long outputs. */\n maxSample?: number;\n}\n\n/**\n * Fraction of n-grams that are duplicates. 0 = every n-gram unique, 1 = total collapse.\n *\n * Healthy prose sits near 0.00-0.10. A model stuck in a loop passes 0.5 quickly.\n * Returns 0 for text too short to judge rather than guessing.\n */\nexport function repetitionScore(text: string, options: RepetitionOptions = {}): number {\n const { n = 3, maxSample = 8000 } = options;\n const w = words(text.slice(0, maxSample));\n if (w.length < n * 4) return 0;\n\n const seen = new Set<string>();\n let total = 0;\n for (let i = 0; i + n <= w.length; i++) {\n seen.add(w.slice(i, i + n).join(' '));\n total++;\n }\n if (total === 0) return 0;\n return clamp01(1 - seen.size / total);\n}\n\nexport interface TailLoopOptions {\n /** How many trailing words to inspect. */\n tailWords?: number;\n /** Longest loop period to look for, in words. */\n maxPeriod?: number;\n /** A block must repeat at least this many times to count as a loop. */\n minRepeats?: number;\n}\n\n/**\n * Detects the specific failure where a model terminates in a repeating tail --\n * the same clause emitted over and over until max_tokens runs out.\n *\n * Whole-output repetition misses this when the first half of the response was fine.\n * Returns the fraction of the inspected tail covered by the loop.\n */\nexport function tailLoopScore(text: string, options: TailLoopOptions = {}): number {\n const { tailWords = 200, maxPeriod = 40, minRepeats = 3 } = options;\n const all = words(text);\n const tail = all.slice(-tailWords);\n if (tail.length < minRepeats * 2) return 0;\n\n let best = 0;\n const periodCap = Math.min(maxPeriod, Math.floor(tail.length / minRepeats));\n for (let p = 1; p <= periodCap; p++) {\n const block = tail.slice(tail.length - p);\n let repeats = 1;\n let cursor = tail.length - p;\n while (cursor - p >= 0) {\n let same = true;\n for (let k = 0; k < p; k++) {\n if (tail[cursor - p + k] !== block[k]) { same = false; break; }\n }\n if (!same) break;\n repeats++;\n cursor -= p;\n }\n if (repeats >= minRepeats) {\n best = Math.max(best, clamp01((repeats * p) / tail.length));\n }\n }\n return best;\n}\n","import { clamp01 } from '../internal/tokenize.js';\n\nexport interface CompressibilityOptions {\n /** Sliding window for back-references, in characters. */\n window?: number;\n /** Only analyse the first N characters. */\n maxSample?: number;\n /** Shortest back-reference worth emitting. */\n minMatch?: number;\n}\n\n/**\n * Greedy LZ77 pass returning emitted-tokens / input-characters.\n *\n * Deliberately hand-rolled instead of node:zlib so the package stays\n * runtime-agnostic (browser, edge, Deno, Bun) and dependency-free.\n * This is not a real compressor; it only needs to move monotonically\n * with redundancy, which is all the score requires.\n *\n * Healthy prose lands around 0.30-0.55. Degenerate output collapses toward 0.\n */\nexport function compressionRatio(text: string, options: CompressibilityOptions = {}): number {\n const { window = 1024, maxSample = 4000, minMatch = 4 } = options;\n const s = text.slice(0, maxSample);\n if (s.length < 64) return 1;\n\n let i = 0;\n let emitted = 0;\n while (i < s.length) {\n let bestLen = 0;\n const start = i > window ? i - window : 0;\n for (let j = start; j < i; j++) {\n let k = 0;\n while (k < 255 && i + k < s.length && s[j + k] === s[i + k]) k++;\n if (k > bestLen) {\n bestLen = k;\n if (bestLen >= 255) break;\n }\n }\n emitted++;\n i += bestLen >= minMatch ? bestLen : 1;\n }\n return emitted / s.length;\n}\n\n/**\n * Suspicion score derived from {@link compressionRatio}.\n * `pivot` is the ratio treated as fully healthy; lower ratios scale up toward 1.\n */\nexport function compressibilityScore(\n text: string,\n options: CompressibilityOptions & { pivot?: number } = {},\n): number {\n const { pivot = 0.32, ...rest } = options;\n if (text.trim().length < 64) return 0;\n return clamp01(1 - compressionRatio(text, rest) / pivot);\n}\n","import { words } from '../internal/tokenize.js';\n\n/**\n * 1 when the response carries no usable content at all.\n *\n * Covers the cases a plain `!text` check misses: whitespace-only, a lone\n * punctuation mark, an empty code fence, or an empty JSON envelope.\n */\nexport function emptinessScore(text: string): number {\n const trimmed = text.trim();\n if (trimmed.length === 0) return 1;\n if (words(trimmed).length === 0) return 1;\n const stripped = trimmed\n .replace(/```[a-z]*\\s*```/gi, '')\n .replace(/^[{}[\\]\"'\\s,.:;!?-]+$/g, '');\n return stripped.trim().length === 0 ? 1 : 0;\n}\n\n/** 1 when the response is shorter than `minChars`, scaling down to 0 at the threshold. */\nexport function shortnessScore(text: string, minChars: number): number {\n if (minChars <= 0) return 0;\n const len = text.trim().length;\n if (len >= minChars) return 0;\n return 1 - len / minChars;\n}\n","export interface TruncationOptions {\n /**\n * The provider's own stop reason, if you have it. When this says the output\n * hit the token ceiling, that is authoritative and the heuristics are skipped.\n */\n finishReason?: string;\n}\n\nconst LENGTH_STOPS = new Set(['length', 'max_tokens', 'maxtokens', 'max_output_tokens', 'token_limit']);\nconst TERMINAL = /[.!?\"'`\\u2019\\u201d)\\]}:;\\u3002\\uff01\\uff1f]\\s*$/;\n\n/**\n * Detects output that stopped mid-thought.\n *\n * Prefers the provider's finish_reason when supplied, because that is ground\n * truth. Falls back to structural signals: unbalanced fences or brackets, or a\n * final sentence with no terminal punctuation.\n *\n * Returns a graded score, not a boolean -- a missing full stop alone is weak\n * evidence and should not sink a response on its own.\n */\nexport function truncationScore(text: string, options: TruncationOptions = {}): number {\n const { finishReason } = options;\n if (finishReason && LENGTH_STOPS.has(finishReason.toLowerCase())) return 1;\n\n const trimmed = text.trim();\n if (trimmed.length === 0) return 0;\n\n let score = 0;\n\n const fences = (trimmed.match(/```/g) ?? []).length;\n if (fences % 2 === 1) score = Math.max(score, 0.9);\n\n for (const [open, close] of [['{', '}'], ['[', ']'], ['(', ')']] as const) {\n const opens = trimmed.split(open).length - 1;\n const closes = trimmed.split(close).length - 1;\n if (opens > closes) score = Math.max(score, 0.8);\n }\n\n if (!TERMINAL.test(trimmed)) score = Math.max(score, 0.55);\n\n return score;\n}\n","export interface JsonOptions {\n /** Allow the payload to sit inside a ```json fence rather than being bare. */\n allowFence?: boolean;\n /** Top-level keys that must be present for the payload to count as valid. */\n requiredKeys?: string[];\n}\n\nexport interface JsonResult {\n /** 0 when the payload parses and satisfies requiredKeys, 1 otherwise. */\n score: number;\n /** The parsed value, when parsing succeeded. */\n value?: unknown;\n reason?: 'unparseable' | 'missing-keys';\n missingKeys?: string[];\n}\n\n/** Pull a JSON payload out of a ```json fence, or return the text unchanged. */\nexport function stripFence(text: string): string {\n const fenced = text.trim().match(/^```(?:json)?\\s*([\\s\\S]*?)\\s*```$/i);\n return fenced ? fenced[1] : text.trim();\n}\n\n/**\n * Structured-output check. Models that \"succeed\" while emitting prose around\n * the JSON, or an object missing half its keys, fail here.\n */\nexport function jsonScore(text: string, options: JsonOptions = {}): JsonResult {\n const { allowFence = true, requiredKeys = [] } = options;\n const candidate = allowFence ? stripFence(text) : text.trim();\n\n let value: unknown;\n try {\n value = JSON.parse(candidate);\n } catch {\n return { score: 1, reason: 'unparseable' };\n }\n\n if (requiredKeys.length > 0) {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n return { score: 1, value, reason: 'missing-keys', missingKeys: [...requiredKeys] };\n }\n const record = value as Record<string, unknown>;\n const missing = requiredKeys.filter((k) => !(k in record));\n if (missing.length > 0) {\n return { score: 1, value, reason: 'missing-keys', missingKeys: missing };\n }\n }\n\n return { score: 0, value };\n}\n","import { words } from '../internal/tokenize.js';\n\n/**\n * Function-word frequency profiles. Coarse by design: this catches a model\n * answering in the wrong language entirely, not dialect or register drift.\n * Off by default in every preset for exactly that reason.\n */\nconst PROFILES: Record<string, Set<string>> = {\n id: new Set(['yang', 'dan', 'di', 'untuk', 'dengan', 'ini', 'itu', 'dari', 'pada', 'tidak', 'adalah', 'akan', 'bisa', 'kita', 'saya', 'atau', 'juga', 'dalam', 'sudah', 'ke']),\n en: new Set(['the', 'and', 'of', 'to', 'in', 'is', 'that', 'for', 'it', 'with', 'as', 'this', 'are', 'be', 'you', 'on', 'not', 'or', 'can', 'we']),\n es: new Set(['el', 'la', 'de', 'que', 'y', 'en', 'los', 'un', 'por', 'con', 'las', 'para', 'una', 'es', 'no', 'se', 'del', 'al', 'lo', 'como']),\n};\n\nexport interface LanguageOptions {\n /** Below this word count the signal is unreliable and the score is 0. */\n minWords?: number;\n}\n\n/** Share of tokens matching each known profile. Not a full language detector. */\nexport function languageProfile(text: string): Record<string, number> {\n const w = words(text);\n const out: Record<string, number> = {};\n if (w.length === 0) return out;\n for (const [lang, set] of Object.entries(PROFILES)) {\n let hits = 0;\n for (const token of w) if (set.has(token)) hits++;\n out[lang] = hits / w.length;\n }\n return out;\n}\n\n/**\n * Suspicion that the response is not in `expected`.\n * Returns 0 for unknown languages or samples too short to judge --\n * silence is better than a confident wrong answer here.\n */\nexport function languageMismatchScore(\n text: string,\n expected: string,\n options: LanguageOptions = {},\n): number {\n const { minWords = 25 } = options;\n if (!(expected in PROFILES)) return 0;\n const w = words(text);\n if (w.length < minWords) return 0;\n\n const profile = languageProfile(text);\n const target = profile[expected] ?? 0;\n const best = Math.max(...Object.values(profile));\n if (best === 0) return 0;\n if (target >= best) return 0;\n return Math.min(1, (best - target) / best);\n}\n\nexport const supportedLanguages = Object.keys(PROFILES);\n","import type { CheckOptions, Reason, ReasonCode, Verdict } from './types.js';\nimport { repetitionScore, tailLoopScore } from './detectors/repetition.js';\nimport { compressibilityScore } from './detectors/compressibility.js';\nimport { emptinessScore, shortnessScore } from './detectors/emptiness.js';\nimport { truncationScore } from './detectors/truncation.js';\nimport { jsonScore } from './detectors/json.js';\nimport { languageMismatchScore } from './detectors/language.js';\nimport { excerpt } from './internal/tokenize.js';\n\nconst DEFAULTS: Required<\n Pick<CheckOptions,\n 'minLength' | 'maxRepetition' | 'maxTailLoop' | 'maxCompressibility' |\n 'maxTruncation' | 'expectJson' | 'allowJsonFence' | 'maxLangMismatch' | 'ngram'>\n> = {\n minLength: 1,\n maxRepetition: 0.35,\n maxTailLoop: 0.5,\n maxCompressibility: 0.75,\n maxTruncation: null as unknown as number,\n expectJson: false,\n allowJsonFence: true,\n maxLangMismatch: 0.6,\n ngram: 3,\n};\n\n/**\n * Runs every enabled detector and returns a structured verdict.\n *\n * Pure and synchronous: no network, no clock, no randomness. The same input\n * always produces the same verdict, which is what makes it safe to put on a\n * hot path and easy to unit test.\n *\n * Every detector runs even after one fails, so `reasons` shows the full picture\n * rather than whichever check happened to be ordered first.\n */\nexport function checkOutput(text: string, options: CheckOptions = {}): Verdict {\n const opts = { ...DEFAULTS, ...options };\n const reasons: Reason[] = [];\n const scores: Partial<Record<ReasonCode, number>> = {};\n let parsedJson: unknown;\n\n const add = (code: ReasonCode, score: number, threshold: number, message: string) => {\n scores[code] = score;\n if (score > threshold) reasons.push({ code, score, threshold, message });\n };\n\n const empty = emptinessScore(text);\n add('EMPTY', empty, 0.5, 'Response contains no usable content.');\n\n // Once the response is empty, the remaining content signals are noise.\n if (empty >= 1) {\n return { ok: false, reasons, scores };\n }\n\n if (opts.minLength > 0) {\n add(\n 'TOO_SHORT',\n shortnessScore(text, opts.minLength),\n 0,\n `Response is ${text.trim().length} chars, below the ${opts.minLength} minimum.`,\n );\n }\n\n if (opts.maxRepetition != null) {\n const s = repetitionScore(text, { n: opts.ngram });\n add('REPETITION', s, opts.maxRepetition,\n `${Math.round(s * 100)}% of ${opts.ngram}-grams are duplicates.`);\n }\n\n if (opts.maxTailLoop != null) {\n const s = tailLoopScore(text);\n add('TAIL_LOOP', s, opts.maxTailLoop,\n `Response ends in a repeating block covering ${Math.round(s * 100)}% of the tail.`);\n }\n\n if (opts.maxCompressibility != null) {\n const s = compressibilityScore(text);\n add('LOW_ENTROPY', s, opts.maxCompressibility,\n 'Response is far more compressible than natural language.');\n }\n\n if (opts.maxTruncation != null || opts.finishReason) {\n const s = truncationScore(text, { finishReason: opts.finishReason });\n add('TRUNCATED', s, opts.maxTruncation ?? 0.75,\n `Response appears cut off near: \"${excerpt(text.trim().slice(-60), 60)}\"`);\n }\n\n if (opts.expectJson) {\n const result = jsonScore(text, {\n allowFence: opts.allowJsonFence,\n requiredKeys: opts.requiredKeys,\n });\n parsedJson = result.value;\n add('INVALID_JSON', result.score, 0,\n result.reason === 'missing-keys'\n ? `JSON is missing required keys: ${result.missingKeys?.join(', ')}.`\n : 'Response is not parseable JSON.');\n }\n\n if (opts.expectLang) {\n const s = languageMismatchScore(text, opts.expectLang);\n add('LANG_MISMATCH', s, opts.maxLangMismatch,\n `Response does not look like '${opts.expectLang}'.`);\n }\n\n return { ok: reasons.length === 0, reasons, scores, json: parsedJson };\n}\n\n/** Error thrown by {@link assertOutput}, carrying the full verdict. */\nexport class DegenerateOutputError extends Error {\n readonly verdict: Verdict;\n /** Marks this as safe to retry against another provider. */\n readonly retryable = true;\n\n constructor(verdict: Verdict) {\n super(`Degenerate LLM output: ${verdict.reasons.map((r) => r.code).join(', ')}`);\n this.name = 'DegenerateOutputError';\n this.verdict = verdict;\n }\n}\n\n/**\n * Throwing wrapper, for dropping straight into an existing retry or fallback\n * chain that already keys off thrown errors.\n */\nexport function assertOutput(text: string, options: CheckOptions = {}): string {\n const verdict = checkOutput(text, options);\n if (!verdict.ok) throw new DegenerateOutputError(verdict);\n return text;\n}\n","import type { CheckOptions } from './types.js';\n\n/**\n * Starting configurations. Tuned against the fixture corpus in test/fixtures,\n * so treat them as calibrated defaults rather than arbitrary numbers -- and\n * still re-tune against your own traffic before trusting them in production.\n */\nexport const presets = {\n /** Conversational replies. Tolerant of quoted or listed repetition. */\n chat: {\n minLength: 12,\n maxRepetition: 0.4,\n maxTailLoop: 0.5,\n maxCompressibility: 0.75,\n } satisfies CheckOptions,\n\n /** Structured output. Parseability is non-negotiable; prose checks relax. */\n strictJson: {\n minLength: 2,\n expectJson: true,\n maxRepetition: 0.6,\n maxTailLoop: 0.6,\n maxCompressibility: null,\n maxTruncation: 0.75,\n } satisfies CheckOptions,\n\n /** Long-form generation, where truncation matters most. */\n longForm: {\n minLength: 200,\n maxRepetition: 0.35,\n maxTailLoop: 0.4,\n maxCompressibility: 0.7,\n maxTruncation: 0.75,\n } satisfies CheckOptions,\n\n /** Catches only unambiguous garbage. Use when false positives cost more than misses. */\n lenient: {\n minLength: 1,\n maxRepetition: 0.7,\n maxTailLoop: 0.7,\n maxCompressibility: 0.9,\n } satisfies CheckOptions,\n} as const;\n"]}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "llm-output-guard",
3
+ "version": "0.1.0",
4
+ "description": "Detect degenerate LLM output that arrives with a 200 OK. Zero dependencies, deterministic, composes with any retry or fallback layer.",
5
+ "keywords": [
6
+ "llm",
7
+ "ai",
8
+ "reliability",
9
+ "fallback",
10
+ "retry",
11
+ "resilience",
12
+ "degenerate",
13
+ "guardrails",
14
+ "validation"
15
+ ],
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "main": "./dist/index.cjs",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "require": "./dist/index.cjs"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "typecheck": "tsc --noEmit",
40
+ "calibrate": "tsx scripts/calibrate.ts",
41
+ "seed": "node scripts/seed-fixtures.mjs",
42
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
43
+ },
44
+ "devDependencies": {
45
+ "@changesets/cli": "^2.27.0",
46
+ "@types/node": "^20.19.43",
47
+ "tsup": "^8.0.0",
48
+ "tsx": "^4.7.0",
49
+ "typescript": "^5.4.0",
50
+ "vitest": "^1.6.0"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/edwinsatya/llm-output-guard.git"
55
+ },
56
+ "homepage": "https://github.com/edwinsatya/llm-output-guard#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/edwinsatya/llm-output-guard/issues"
59
+ },
60
+ "author": "Edwin Satya Yudistira"
61
+ }