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.
@@ -0,0 +1,233 @@
1
+ type ReasonCode = 'EMPTY' | 'TOO_SHORT' | 'REPETITION' | 'TAIL_LOOP' | 'LOW_ENTROPY' | 'TRUNCATED' | 'INVALID_JSON' | 'LANG_MISMATCH';
2
+ interface Reason {
3
+ code: ReasonCode;
4
+ /** 0..1. Higher means more suspicious. */
5
+ score: number;
6
+ /** The threshold this score crossed. */
7
+ threshold: number;
8
+ /** Human-readable explanation, safe to log. */
9
+ message: string;
10
+ }
11
+ interface Verdict {
12
+ /** True when nothing crossed its threshold. */
13
+ ok: boolean;
14
+ /** Every failing signal, not just the first -- more useful when debugging. */
15
+ reasons: Reason[];
16
+ /** Every score computed, including passing ones. Feed these to your metrics. */
17
+ scores: Partial<Record<ReasonCode, number>>;
18
+ /** Parsed JSON payload when `json` was enabled and parsing succeeded. */
19
+ json?: unknown;
20
+ }
21
+ interface CheckOptions {
22
+ /** Minimum acceptable length in characters. Set 0 to disable. Default 1. */
23
+ minLength?: number;
24
+ /** Duplicate-n-gram threshold. Set null to disable. Default 0.35. */
25
+ maxRepetition?: number | null;
26
+ /** Tail-loop threshold. Set null to disable. Default 0.5. */
27
+ maxTailLoop?: number | null;
28
+ /** Compressibility threshold. Set null to disable. Default 0.75. */
29
+ maxCompressibility?: number | null;
30
+ /** Truncation threshold. Set null to disable. Default null. */
31
+ maxTruncation?: number | null;
32
+ /** Provider stop reason, used by the truncation detector when present. */
33
+ finishReason?: string;
34
+ /** Require a parseable JSON payload. Default false. */
35
+ expectJson?: boolean;
36
+ /** Allow the JSON payload to be wrapped in a fence. Default true. */
37
+ allowJsonFence?: boolean;
38
+ /** Top-level keys the JSON payload must contain. */
39
+ requiredKeys?: string[];
40
+ /** Expected language code ('id' | 'en' | 'es'). Off by default. */
41
+ expectLang?: string | null;
42
+ /** Language-mismatch threshold. Default 0.6. */
43
+ maxLangMismatch?: number;
44
+ /** N-gram size for the repetition detector. Default 3. */
45
+ ngram?: number;
46
+ }
47
+
48
+ /**
49
+ * Runs every enabled detector and returns a structured verdict.
50
+ *
51
+ * Pure and synchronous: no network, no clock, no randomness. The same input
52
+ * always produces the same verdict, which is what makes it safe to put on a
53
+ * hot path and easy to unit test.
54
+ *
55
+ * Every detector runs even after one fails, so `reasons` shows the full picture
56
+ * rather than whichever check happened to be ordered first.
57
+ */
58
+ declare function checkOutput(text: string, options?: CheckOptions): Verdict;
59
+ /** Error thrown by {@link assertOutput}, carrying the full verdict. */
60
+ declare class DegenerateOutputError extends Error {
61
+ readonly verdict: Verdict;
62
+ /** Marks this as safe to retry against another provider. */
63
+ readonly retryable = true;
64
+ constructor(verdict: Verdict);
65
+ }
66
+ /**
67
+ * Throwing wrapper, for dropping straight into an existing retry or fallback
68
+ * chain that already keys off thrown errors.
69
+ */
70
+ declare function assertOutput(text: string, options?: CheckOptions): string;
71
+
72
+ /**
73
+ * Starting configurations. Tuned against the fixture corpus in test/fixtures,
74
+ * so treat them as calibrated defaults rather than arbitrary numbers -- and
75
+ * still re-tune against your own traffic before trusting them in production.
76
+ */
77
+ declare const presets: {
78
+ /** Conversational replies. Tolerant of quoted or listed repetition. */
79
+ readonly chat: {
80
+ minLength: number;
81
+ maxRepetition: number;
82
+ maxTailLoop: number;
83
+ maxCompressibility: number;
84
+ };
85
+ /** Structured output. Parseability is non-negotiable; prose checks relax. */
86
+ readonly strictJson: {
87
+ minLength: number;
88
+ expectJson: true;
89
+ maxRepetition: number;
90
+ maxTailLoop: number;
91
+ maxCompressibility: null;
92
+ maxTruncation: number;
93
+ };
94
+ /** Long-form generation, where truncation matters most. */
95
+ readonly longForm: {
96
+ minLength: number;
97
+ maxRepetition: number;
98
+ maxTailLoop: number;
99
+ maxCompressibility: number;
100
+ maxTruncation: number;
101
+ };
102
+ /** Catches only unambiguous garbage. Use when false positives cost more than misses. */
103
+ readonly lenient: {
104
+ minLength: number;
105
+ maxRepetition: number;
106
+ maxTailLoop: number;
107
+ maxCompressibility: number;
108
+ };
109
+ };
110
+
111
+ interface RepetitionOptions {
112
+ /** N-gram size. 3 suits prose; 2 is noisy, 4 misses short loops. */
113
+ n?: number;
114
+ /** Only analyse the first N characters. Keeps cost bounded on long outputs. */
115
+ maxSample?: number;
116
+ }
117
+ /**
118
+ * Fraction of n-grams that are duplicates. 0 = every n-gram unique, 1 = total collapse.
119
+ *
120
+ * Healthy prose sits near 0.00-0.10. A model stuck in a loop passes 0.5 quickly.
121
+ * Returns 0 for text too short to judge rather than guessing.
122
+ */
123
+ declare function repetitionScore(text: string, options?: RepetitionOptions): number;
124
+ interface TailLoopOptions {
125
+ /** How many trailing words to inspect. */
126
+ tailWords?: number;
127
+ /** Longest loop period to look for, in words. */
128
+ maxPeriod?: number;
129
+ /** A block must repeat at least this many times to count as a loop. */
130
+ minRepeats?: number;
131
+ }
132
+ /**
133
+ * Detects the specific failure where a model terminates in a repeating tail --
134
+ * the same clause emitted over and over until max_tokens runs out.
135
+ *
136
+ * Whole-output repetition misses this when the first half of the response was fine.
137
+ * Returns the fraction of the inspected tail covered by the loop.
138
+ */
139
+ declare function tailLoopScore(text: string, options?: TailLoopOptions): number;
140
+
141
+ interface CompressibilityOptions {
142
+ /** Sliding window for back-references, in characters. */
143
+ window?: number;
144
+ /** Only analyse the first N characters. */
145
+ maxSample?: number;
146
+ /** Shortest back-reference worth emitting. */
147
+ minMatch?: number;
148
+ }
149
+ /**
150
+ * Greedy LZ77 pass returning emitted-tokens / input-characters.
151
+ *
152
+ * Deliberately hand-rolled instead of node:zlib so the package stays
153
+ * runtime-agnostic (browser, edge, Deno, Bun) and dependency-free.
154
+ * This is not a real compressor; it only needs to move monotonically
155
+ * with redundancy, which is all the score requires.
156
+ *
157
+ * Healthy prose lands around 0.30-0.55. Degenerate output collapses toward 0.
158
+ */
159
+ declare function compressionRatio(text: string, options?: CompressibilityOptions): number;
160
+ /**
161
+ * Suspicion score derived from {@link compressionRatio}.
162
+ * `pivot` is the ratio treated as fully healthy; lower ratios scale up toward 1.
163
+ */
164
+ declare function compressibilityScore(text: string, options?: CompressibilityOptions & {
165
+ pivot?: number;
166
+ }): number;
167
+
168
+ /**
169
+ * 1 when the response carries no usable content at all.
170
+ *
171
+ * Covers the cases a plain `!text` check misses: whitespace-only, a lone
172
+ * punctuation mark, an empty code fence, or an empty JSON envelope.
173
+ */
174
+ declare function emptinessScore(text: string): number;
175
+ /** 1 when the response is shorter than `minChars`, scaling down to 0 at the threshold. */
176
+ declare function shortnessScore(text: string, minChars: number): number;
177
+
178
+ interface TruncationOptions {
179
+ /**
180
+ * The provider's own stop reason, if you have it. When this says the output
181
+ * hit the token ceiling, that is authoritative and the heuristics are skipped.
182
+ */
183
+ finishReason?: string;
184
+ }
185
+ /**
186
+ * Detects output that stopped mid-thought.
187
+ *
188
+ * Prefers the provider's finish_reason when supplied, because that is ground
189
+ * truth. Falls back to structural signals: unbalanced fences or brackets, or a
190
+ * final sentence with no terminal punctuation.
191
+ *
192
+ * Returns a graded score, not a boolean -- a missing full stop alone is weak
193
+ * evidence and should not sink a response on its own.
194
+ */
195
+ declare function truncationScore(text: string, options?: TruncationOptions): number;
196
+
197
+ interface JsonOptions {
198
+ /** Allow the payload to sit inside a ```json fence rather than being bare. */
199
+ allowFence?: boolean;
200
+ /** Top-level keys that must be present for the payload to count as valid. */
201
+ requiredKeys?: string[];
202
+ }
203
+ interface JsonResult {
204
+ /** 0 when the payload parses and satisfies requiredKeys, 1 otherwise. */
205
+ score: number;
206
+ /** The parsed value, when parsing succeeded. */
207
+ value?: unknown;
208
+ reason?: 'unparseable' | 'missing-keys';
209
+ missingKeys?: string[];
210
+ }
211
+ /** Pull a JSON payload out of a ```json fence, or return the text unchanged. */
212
+ declare function stripFence(text: string): string;
213
+ /**
214
+ * Structured-output check. Models that "succeed" while emitting prose around
215
+ * the JSON, or an object missing half its keys, fail here.
216
+ */
217
+ declare function jsonScore(text: string, options?: JsonOptions): JsonResult;
218
+
219
+ interface LanguageOptions {
220
+ /** Below this word count the signal is unreliable and the score is 0. */
221
+ minWords?: number;
222
+ }
223
+ /** Share of tokens matching each known profile. Not a full language detector. */
224
+ declare function languageProfile(text: string): Record<string, number>;
225
+ /**
226
+ * Suspicion that the response is not in `expected`.
227
+ * Returns 0 for unknown languages or samples too short to judge --
228
+ * silence is better than a confident wrong answer here.
229
+ */
230
+ declare function languageMismatchScore(text: string, expected: string, options?: LanguageOptions): number;
231
+ declare const supportedLanguages: string[];
232
+
233
+ export { type CheckOptions, type CompressibilityOptions, DegenerateOutputError, type JsonOptions, type JsonResult, type LanguageOptions, type Reason, type ReasonCode, type RepetitionOptions, type TailLoopOptions, type TruncationOptions, type Verdict, assertOutput, checkOutput, compressibilityScore, compressionRatio, emptinessScore, jsonScore, languageMismatchScore, languageProfile, presets, repetitionScore, shortnessScore, stripFence, supportedLanguages, tailLoopScore, truncationScore };
@@ -0,0 +1,233 @@
1
+ type ReasonCode = 'EMPTY' | 'TOO_SHORT' | 'REPETITION' | 'TAIL_LOOP' | 'LOW_ENTROPY' | 'TRUNCATED' | 'INVALID_JSON' | 'LANG_MISMATCH';
2
+ interface Reason {
3
+ code: ReasonCode;
4
+ /** 0..1. Higher means more suspicious. */
5
+ score: number;
6
+ /** The threshold this score crossed. */
7
+ threshold: number;
8
+ /** Human-readable explanation, safe to log. */
9
+ message: string;
10
+ }
11
+ interface Verdict {
12
+ /** True when nothing crossed its threshold. */
13
+ ok: boolean;
14
+ /** Every failing signal, not just the first -- more useful when debugging. */
15
+ reasons: Reason[];
16
+ /** Every score computed, including passing ones. Feed these to your metrics. */
17
+ scores: Partial<Record<ReasonCode, number>>;
18
+ /** Parsed JSON payload when `json` was enabled and parsing succeeded. */
19
+ json?: unknown;
20
+ }
21
+ interface CheckOptions {
22
+ /** Minimum acceptable length in characters. Set 0 to disable. Default 1. */
23
+ minLength?: number;
24
+ /** Duplicate-n-gram threshold. Set null to disable. Default 0.35. */
25
+ maxRepetition?: number | null;
26
+ /** Tail-loop threshold. Set null to disable. Default 0.5. */
27
+ maxTailLoop?: number | null;
28
+ /** Compressibility threshold. Set null to disable. Default 0.75. */
29
+ maxCompressibility?: number | null;
30
+ /** Truncation threshold. Set null to disable. Default null. */
31
+ maxTruncation?: number | null;
32
+ /** Provider stop reason, used by the truncation detector when present. */
33
+ finishReason?: string;
34
+ /** Require a parseable JSON payload. Default false. */
35
+ expectJson?: boolean;
36
+ /** Allow the JSON payload to be wrapped in a fence. Default true. */
37
+ allowJsonFence?: boolean;
38
+ /** Top-level keys the JSON payload must contain. */
39
+ requiredKeys?: string[];
40
+ /** Expected language code ('id' | 'en' | 'es'). Off by default. */
41
+ expectLang?: string | null;
42
+ /** Language-mismatch threshold. Default 0.6. */
43
+ maxLangMismatch?: number;
44
+ /** N-gram size for the repetition detector. Default 3. */
45
+ ngram?: number;
46
+ }
47
+
48
+ /**
49
+ * Runs every enabled detector and returns a structured verdict.
50
+ *
51
+ * Pure and synchronous: no network, no clock, no randomness. The same input
52
+ * always produces the same verdict, which is what makes it safe to put on a
53
+ * hot path and easy to unit test.
54
+ *
55
+ * Every detector runs even after one fails, so `reasons` shows the full picture
56
+ * rather than whichever check happened to be ordered first.
57
+ */
58
+ declare function checkOutput(text: string, options?: CheckOptions): Verdict;
59
+ /** Error thrown by {@link assertOutput}, carrying the full verdict. */
60
+ declare class DegenerateOutputError extends Error {
61
+ readonly verdict: Verdict;
62
+ /** Marks this as safe to retry against another provider. */
63
+ readonly retryable = true;
64
+ constructor(verdict: Verdict);
65
+ }
66
+ /**
67
+ * Throwing wrapper, for dropping straight into an existing retry or fallback
68
+ * chain that already keys off thrown errors.
69
+ */
70
+ declare function assertOutput(text: string, options?: CheckOptions): string;
71
+
72
+ /**
73
+ * Starting configurations. Tuned against the fixture corpus in test/fixtures,
74
+ * so treat them as calibrated defaults rather than arbitrary numbers -- and
75
+ * still re-tune against your own traffic before trusting them in production.
76
+ */
77
+ declare const presets: {
78
+ /** Conversational replies. Tolerant of quoted or listed repetition. */
79
+ readonly chat: {
80
+ minLength: number;
81
+ maxRepetition: number;
82
+ maxTailLoop: number;
83
+ maxCompressibility: number;
84
+ };
85
+ /** Structured output. Parseability is non-negotiable; prose checks relax. */
86
+ readonly strictJson: {
87
+ minLength: number;
88
+ expectJson: true;
89
+ maxRepetition: number;
90
+ maxTailLoop: number;
91
+ maxCompressibility: null;
92
+ maxTruncation: number;
93
+ };
94
+ /** Long-form generation, where truncation matters most. */
95
+ readonly longForm: {
96
+ minLength: number;
97
+ maxRepetition: number;
98
+ maxTailLoop: number;
99
+ maxCompressibility: number;
100
+ maxTruncation: number;
101
+ };
102
+ /** Catches only unambiguous garbage. Use when false positives cost more than misses. */
103
+ readonly lenient: {
104
+ minLength: number;
105
+ maxRepetition: number;
106
+ maxTailLoop: number;
107
+ maxCompressibility: number;
108
+ };
109
+ };
110
+
111
+ interface RepetitionOptions {
112
+ /** N-gram size. 3 suits prose; 2 is noisy, 4 misses short loops. */
113
+ n?: number;
114
+ /** Only analyse the first N characters. Keeps cost bounded on long outputs. */
115
+ maxSample?: number;
116
+ }
117
+ /**
118
+ * Fraction of n-grams that are duplicates. 0 = every n-gram unique, 1 = total collapse.
119
+ *
120
+ * Healthy prose sits near 0.00-0.10. A model stuck in a loop passes 0.5 quickly.
121
+ * Returns 0 for text too short to judge rather than guessing.
122
+ */
123
+ declare function repetitionScore(text: string, options?: RepetitionOptions): number;
124
+ interface TailLoopOptions {
125
+ /** How many trailing words to inspect. */
126
+ tailWords?: number;
127
+ /** Longest loop period to look for, in words. */
128
+ maxPeriod?: number;
129
+ /** A block must repeat at least this many times to count as a loop. */
130
+ minRepeats?: number;
131
+ }
132
+ /**
133
+ * Detects the specific failure where a model terminates in a repeating tail --
134
+ * the same clause emitted over and over until max_tokens runs out.
135
+ *
136
+ * Whole-output repetition misses this when the first half of the response was fine.
137
+ * Returns the fraction of the inspected tail covered by the loop.
138
+ */
139
+ declare function tailLoopScore(text: string, options?: TailLoopOptions): number;
140
+
141
+ interface CompressibilityOptions {
142
+ /** Sliding window for back-references, in characters. */
143
+ window?: number;
144
+ /** Only analyse the first N characters. */
145
+ maxSample?: number;
146
+ /** Shortest back-reference worth emitting. */
147
+ minMatch?: number;
148
+ }
149
+ /**
150
+ * Greedy LZ77 pass returning emitted-tokens / input-characters.
151
+ *
152
+ * Deliberately hand-rolled instead of node:zlib so the package stays
153
+ * runtime-agnostic (browser, edge, Deno, Bun) and dependency-free.
154
+ * This is not a real compressor; it only needs to move monotonically
155
+ * with redundancy, which is all the score requires.
156
+ *
157
+ * Healthy prose lands around 0.30-0.55. Degenerate output collapses toward 0.
158
+ */
159
+ declare function compressionRatio(text: string, options?: CompressibilityOptions): number;
160
+ /**
161
+ * Suspicion score derived from {@link compressionRatio}.
162
+ * `pivot` is the ratio treated as fully healthy; lower ratios scale up toward 1.
163
+ */
164
+ declare function compressibilityScore(text: string, options?: CompressibilityOptions & {
165
+ pivot?: number;
166
+ }): number;
167
+
168
+ /**
169
+ * 1 when the response carries no usable content at all.
170
+ *
171
+ * Covers the cases a plain `!text` check misses: whitespace-only, a lone
172
+ * punctuation mark, an empty code fence, or an empty JSON envelope.
173
+ */
174
+ declare function emptinessScore(text: string): number;
175
+ /** 1 when the response is shorter than `minChars`, scaling down to 0 at the threshold. */
176
+ declare function shortnessScore(text: string, minChars: number): number;
177
+
178
+ interface TruncationOptions {
179
+ /**
180
+ * The provider's own stop reason, if you have it. When this says the output
181
+ * hit the token ceiling, that is authoritative and the heuristics are skipped.
182
+ */
183
+ finishReason?: string;
184
+ }
185
+ /**
186
+ * Detects output that stopped mid-thought.
187
+ *
188
+ * Prefers the provider's finish_reason when supplied, because that is ground
189
+ * truth. Falls back to structural signals: unbalanced fences or brackets, or a
190
+ * final sentence with no terminal punctuation.
191
+ *
192
+ * Returns a graded score, not a boolean -- a missing full stop alone is weak
193
+ * evidence and should not sink a response on its own.
194
+ */
195
+ declare function truncationScore(text: string, options?: TruncationOptions): number;
196
+
197
+ interface JsonOptions {
198
+ /** Allow the payload to sit inside a ```json fence rather than being bare. */
199
+ allowFence?: boolean;
200
+ /** Top-level keys that must be present for the payload to count as valid. */
201
+ requiredKeys?: string[];
202
+ }
203
+ interface JsonResult {
204
+ /** 0 when the payload parses and satisfies requiredKeys, 1 otherwise. */
205
+ score: number;
206
+ /** The parsed value, when parsing succeeded. */
207
+ value?: unknown;
208
+ reason?: 'unparseable' | 'missing-keys';
209
+ missingKeys?: string[];
210
+ }
211
+ /** Pull a JSON payload out of a ```json fence, or return the text unchanged. */
212
+ declare function stripFence(text: string): string;
213
+ /**
214
+ * Structured-output check. Models that "succeed" while emitting prose around
215
+ * the JSON, or an object missing half its keys, fail here.
216
+ */
217
+ declare function jsonScore(text: string, options?: JsonOptions): JsonResult;
218
+
219
+ interface LanguageOptions {
220
+ /** Below this word count the signal is unreliable and the score is 0. */
221
+ minWords?: number;
222
+ }
223
+ /** Share of tokens matching each known profile. Not a full language detector. */
224
+ declare function languageProfile(text: string): Record<string, number>;
225
+ /**
226
+ * Suspicion that the response is not in `expected`.
227
+ * Returns 0 for unknown languages or samples too short to judge --
228
+ * silence is better than a confident wrong answer here.
229
+ */
230
+ declare function languageMismatchScore(text: string, expected: string, options?: LanguageOptions): number;
231
+ declare const supportedLanguages: string[];
232
+
233
+ export { type CheckOptions, type CompressibilityOptions, DegenerateOutputError, type JsonOptions, type JsonResult, type LanguageOptions, type Reason, type ReasonCode, type RepetitionOptions, type TailLoopOptions, type TruncationOptions, type Verdict, assertOutput, checkOutput, compressibilityScore, compressionRatio, emptinessScore, jsonScore, languageMismatchScore, languageProfile, presets, repetitionScore, shortnessScore, stripFence, supportedLanguages, tailLoopScore, truncationScore };