llm-output-guard 0.2.0 → 0.3.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,150 @@
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
+ interface StreamGuardOptions extends CheckOptions {
49
+ /**
50
+ * Characters of *new* text between checks. Default 400.
51
+ *
52
+ * Checking on every chunk would re-scan the buffer per token and turn a
53
+ * linear stream into quadratic work. Batching costs a little detection
54
+ * latency and buys a bounded cost per stream.
55
+ */
56
+ checkEvery?: number;
57
+ /**
58
+ * Characters that must arrive before any judgement. Default 240.
59
+ *
60
+ * A loop is not visible in the first sentence, and neither is its absence.
61
+ * Below this the guard abstains rather than guessing -- the same rule the
62
+ * detectors already follow for short samples.
63
+ */
64
+ warmup?: number;
65
+ /**
66
+ * Trailing characters each mid-stream check looks at. Default 2000.
67
+ *
68
+ * Two reasons, and the second matters more. Cost: without a window every
69
+ * check re-scans the whole buffer, so a stream costs quadratic work in its
70
+ * own length. Sensitivity: a model that produced four healthy paragraphs
71
+ * and then began looping is diluted to nothing when measured across all
72
+ * five, which is the same reasoning that makes `tailLoopScore` a separate
73
+ * detector from `repetitionScore`. Recent text is the text in question.
74
+ */
75
+ window?: number;
76
+ }
77
+ interface StreamGuard {
78
+ /**
79
+ * Feed the next chunk.
80
+ *
81
+ * Returns a verdict only on the chunks where a check actually ran, and
82
+ * `null` on the rest -- so `null` means "not judged yet", never "healthy".
83
+ * Read `.ok` on what you get back.
84
+ */
85
+ push(chunk: string): Verdict | null;
86
+ /**
87
+ * Full check on the complete text, including the detectors deferred above.
88
+ * Pass the provider's stop reason if you have it; truncation keys off it.
89
+ */
90
+ end(finishReason?: string): Verdict;
91
+ /** Everything pushed so far. */
92
+ readonly text: string;
93
+ /** How many mid-stream checks have run. Useful when tuning `checkEvery`. */
94
+ readonly checks: number;
95
+ }
96
+ /**
97
+ * Watches a response as it arrives and reports degeneration before it finishes.
98
+ *
99
+ * The reason to bother: a model that has started looping will keep looping
100
+ * until it hits `max_tokens`, and you pay for every one of those tokens plus
101
+ * the latency of waiting for them. Catching it at character 300 of a 4000
102
+ * character run and aborting turns a slow bad answer into a fast one.
103
+ *
104
+ * This never aborts anything itself -- it holds no controller and knows
105
+ * nothing about your provider. It tells you; you decide.
106
+ */
107
+ declare function createStreamGuard(options?: StreamGuardOptions): StreamGuard;
108
+ interface GuardStreamOptions extends StreamGuardOptions {
109
+ /**
110
+ * Called the first time a mid-stream check fails. Abort your request here.
111
+ *
112
+ * The guard deliberately does not own the AbortController: the thing that
113
+ * knows how to cancel a generation is the code that started it, and a
114
+ * detection library that reaches into your transport is a library you
115
+ * cannot use with the next transport.
116
+ */
117
+ onDegenerate?: (verdict: Verdict) => void;
118
+ /**
119
+ * Called once with the final verdict when the source ends normally. Skipped
120
+ * when the stream was cut short, because a verdict on a deliberately
121
+ * abandoned response would describe your own abort, not the model.
122
+ */
123
+ onEnd?: (verdict: Verdict) => void;
124
+ /**
125
+ * Stop yielding once degeneration is detected. Default true.
126
+ *
127
+ * Set false to keep passing chunks through while still being told -- useful
128
+ * for a logging-only rollout, where you want the signal without changing
129
+ * what the user sees.
130
+ */
131
+ stopOnDegenerate?: boolean;
132
+ }
133
+ /**
134
+ * Wraps a chunk stream and cuts it off when the model starts looping.
135
+ *
136
+ * ```ts
137
+ * const controller = new AbortController();
138
+ * const guarded = guardStream(model.textStream, {
139
+ * ...presets.chat,
140
+ * onDegenerate: () => controller.abort(),
141
+ * });
142
+ * for await (const chunk of guarded) process.stdout.write(chunk);
143
+ * ```
144
+ *
145
+ * Yields the source's chunks unchanged until then, so it drops into an
146
+ * existing loop without touching what you do with the text.
147
+ */
148
+ declare function guardStream(source: AsyncIterable<string>, options?: GuardStreamOptions): AsyncGenerator<string, void, undefined>;
149
+
150
+ export { type CheckOptions as C, type GuardStreamOptions as G, type Reason as R, type StreamGuardOptions as S, type Verdict as V, type ReasonCode as a, type StreamGuard as b, createStreamGuard as c, guardStream as g };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-output-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Detect degenerate LLM output that arrives with a 200 OK. Zero dependencies, deterministic, composes with any retry or fallback layer.",
5
5
  "keywords": [
6
6
  "llm",
@@ -24,7 +24,13 @@
24
24
  "types": "./dist/index.d.ts",
25
25
  "import": "./dist/index.js",
26
26
  "require": "./dist/index.cjs"
27
- }
27
+ },
28
+ "./ai-sdk": {
29
+ "types": "./dist/ai-sdk.d.ts",
30
+ "import": "./dist/ai-sdk.js",
31
+ "require": "./dist/ai-sdk.cjs"
32
+ },
33
+ "./package.json": "./package.json"
28
34
  },
29
35
  "files": [
30
36
  "dist"
@@ -41,9 +47,18 @@
41
47
  "seed": "node scripts/seed-fixtures.mjs",
42
48
  "prepublishOnly": "npm run typecheck && npm run test && npm run build"
43
49
  },
50
+ "peerDependencies": {
51
+ "ai": ">=4"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "ai": {
55
+ "optional": true
56
+ }
57
+ },
44
58
  "devDependencies": {
45
59
  "@changesets/cli": "^2.27.0",
46
60
  "@types/node": "^20.19.43",
61
+ "ai": "^7.0.62",
47
62
  "tsup": "^8.0.0",
48
63
  "tsx": "^4.7.0",
49
64
  "typescript": "^5.4.0",