@almadar/llm 1.0.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,308 @@
1
+ /**
2
+ * Truncation Detector
3
+ *
4
+ * Utilities for detecting when LLM output has been truncated and
5
+ * extracting usable content from partial responses.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { LLMFinishReason } from './client.js';
11
+ import { autoCloseJson } from './json-parser.js';
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ export type TruncationReason =
18
+ | 'finish_reason'
19
+ | 'json_incomplete'
20
+ | 'bracket_mismatch'
21
+ | 'none';
22
+
23
+ export interface TruncationResult {
24
+ isTruncated: boolean;
25
+ reason: TruncationReason;
26
+ partialContent?: string;
27
+ lastCompleteElement?: unknown;
28
+ missingCloseBrackets?: number;
29
+ missingCloseBraces?: number;
30
+ }
31
+
32
+ // ============================================================================
33
+ // Main Detection Function
34
+ // ============================================================================
35
+
36
+ export function detectTruncation(
37
+ response: string,
38
+ finishReason: LLMFinishReason,
39
+ ): TruncationResult {
40
+ if (finishReason === 'length') {
41
+ const bracketInfo = countBrackets(response);
42
+ return {
43
+ isTruncated: true,
44
+ reason: 'finish_reason',
45
+ partialContent: response,
46
+ lastCompleteElement: findLastCompleteElement(response),
47
+ missingCloseBrackets: bracketInfo.missingCloseBrackets,
48
+ missingCloseBraces: bracketInfo.missingCloseBraces,
49
+ };
50
+ }
51
+
52
+ try {
53
+ JSON.parse(response);
54
+ return { isTruncated: false, reason: 'none' };
55
+ } catch {
56
+ // JSON is invalid, check if due to truncation
57
+ }
58
+
59
+ if (finishReason === 'stop' || finishReason === null) {
60
+ const trimmed = response.trim();
61
+
62
+ const isMidContent =
63
+ trimmed.endsWith(',') ||
64
+ trimmed.endsWith(':') ||
65
+ trimmed.endsWith('": ') ||
66
+ /:\s*$/.test(trimmed) ||
67
+ /,\s*$/.test(trimmed);
68
+
69
+ if (isMidContent) {
70
+ const bracketInfo = countBrackets(response);
71
+ return {
72
+ isTruncated: true,
73
+ reason: 'json_incomplete',
74
+ partialContent: response,
75
+ lastCompleteElement: findLastCompleteElement(response),
76
+ missingCloseBrackets: bracketInfo.missingCloseBrackets,
77
+ missingCloseBraces: bracketInfo.missingCloseBraces,
78
+ };
79
+ }
80
+
81
+ try {
82
+ const closed = autoCloseJson(trimmed);
83
+ JSON.parse(closed);
84
+ return { isTruncated: false, reason: 'none' };
85
+ } catch {
86
+ return { isTruncated: false, reason: 'none' };
87
+ }
88
+ }
89
+
90
+ const bracketInfo = countBrackets(response);
91
+ if (
92
+ bracketInfo.missingCloseBrackets > 0 ||
93
+ bracketInfo.missingCloseBraces > 0
94
+ ) {
95
+ return {
96
+ isTruncated: true,
97
+ reason: 'bracket_mismatch',
98
+ partialContent: response,
99
+ lastCompleteElement: findLastCompleteElement(response),
100
+ missingCloseBrackets: bracketInfo.missingCloseBrackets,
101
+ missingCloseBraces: bracketInfo.missingCloseBraces,
102
+ };
103
+ }
104
+
105
+ return { isTruncated: false, reason: 'none' };
106
+ }
107
+
108
+ // ============================================================================
109
+ // Helper Functions
110
+ // ============================================================================
111
+
112
+ function countBrackets(json: string): {
113
+ openBrackets: number;
114
+ closeBrackets: number;
115
+ openBraces: number;
116
+ closeBraces: number;
117
+ missingCloseBrackets: number;
118
+ missingCloseBraces: number;
119
+ } {
120
+ let inString = false;
121
+ let escaped = false;
122
+ let openBrackets = 0;
123
+ let closeBrackets = 0;
124
+ let openBraces = 0;
125
+ let closeBraces = 0;
126
+
127
+ for (const char of json) {
128
+ if (escaped) {
129
+ escaped = false;
130
+ continue;
131
+ }
132
+ if (char === '\\' && inString) {
133
+ escaped = true;
134
+ continue;
135
+ }
136
+ if (char === '"') {
137
+ inString = !inString;
138
+ continue;
139
+ }
140
+ if (inString) continue;
141
+
142
+ switch (char) {
143
+ case '[':
144
+ openBrackets++;
145
+ break;
146
+ case ']':
147
+ closeBrackets++;
148
+ break;
149
+ case '{':
150
+ openBraces++;
151
+ break;
152
+ case '}':
153
+ closeBraces++;
154
+ break;
155
+ }
156
+ }
157
+
158
+ return {
159
+ openBrackets,
160
+ closeBrackets,
161
+ openBraces,
162
+ closeBraces,
163
+ missingCloseBrackets: Math.max(0, openBrackets - closeBrackets),
164
+ missingCloseBraces: Math.max(0, openBraces - closeBraces),
165
+ };
166
+ }
167
+
168
+ export function findLastCompleteElement(json: string): unknown | null {
169
+ const autoClosed = autoCloseJson(json);
170
+ try {
171
+ return JSON.parse(autoClosed);
172
+ } catch {
173
+ // Auto-close didn't work
174
+ }
175
+
176
+ const trimmed = json.trim();
177
+
178
+ if (trimmed.startsWith('[')) {
179
+ const lastCompleteIndex = findLastCompleteArrayElement(trimmed);
180
+ if (lastCompleteIndex > 0) {
181
+ const subset = trimmed.substring(0, lastCompleteIndex) + ']';
182
+ try {
183
+ return JSON.parse(subset);
184
+ } catch {
185
+ // Continue
186
+ }
187
+ }
188
+ }
189
+
190
+ if (trimmed.startsWith('{')) {
191
+ const closed = autoCloseJson(trimmed);
192
+ try {
193
+ return JSON.parse(closed);
194
+ } catch {
195
+ const lastCompleteIndex = findLastCompleteObjectProperty(trimmed);
196
+ if (lastCompleteIndex > 0) {
197
+ const subset = trimmed.substring(0, lastCompleteIndex) + '}';
198
+ try {
199
+ return JSON.parse(subset);
200
+ } catch {
201
+ // Give up
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ return null;
208
+ }
209
+
210
+ function findLastCompleteArrayElement(json: string): number {
211
+ let depth = 0;
212
+ let inString = false;
213
+ let escaped = false;
214
+ let lastCompleteElementEnd = -1;
215
+
216
+ for (let i = 0; i < json.length; i++) {
217
+ const char = json[i];
218
+
219
+ if (escaped) {
220
+ escaped = false;
221
+ continue;
222
+ }
223
+ if (char === '\\' && inString) {
224
+ escaped = true;
225
+ continue;
226
+ }
227
+ if (char === '"') {
228
+ inString = !inString;
229
+ continue;
230
+ }
231
+ if (inString) continue;
232
+
233
+ if (char === '[' || char === '{') {
234
+ depth++;
235
+ } else if (char === ']' || char === '}') {
236
+ depth--;
237
+ if (depth === 1) {
238
+ lastCompleteElementEnd = i + 1;
239
+ }
240
+ } else if (char === ',' && depth === 1) {
241
+ lastCompleteElementEnd = i;
242
+ }
243
+ }
244
+
245
+ return lastCompleteElementEnd > 0 ? lastCompleteElementEnd : -1;
246
+ }
247
+
248
+ function findLastCompleteObjectProperty(json: string): number {
249
+ let depth = 0;
250
+ let inString = false;
251
+ let escaped = false;
252
+ let lastCommaIndex = -1;
253
+
254
+ for (let i = 0; i < json.length; i++) {
255
+ const char = json[i];
256
+
257
+ if (escaped) {
258
+ escaped = false;
259
+ continue;
260
+ }
261
+ if (char === '\\' && inString) {
262
+ escaped = true;
263
+ continue;
264
+ }
265
+ if (char === '"') {
266
+ inString = !inString;
267
+ continue;
268
+ }
269
+ if (inString) continue;
270
+
271
+ if (char === '[' || char === '{') {
272
+ depth++;
273
+ } else if (char === ']' || char === '}') {
274
+ depth--;
275
+ } else if (char === ',' && depth === 1) {
276
+ lastCommaIndex = i;
277
+ }
278
+ }
279
+
280
+ return lastCommaIndex > 0 ? lastCommaIndex : -1;
281
+ }
282
+
283
+ export function isLikelyTruncated(content: string): boolean {
284
+ const trimmed = content.trim();
285
+ if (!trimmed) return false;
286
+
287
+ const brackets = countBrackets(trimmed);
288
+ if (
289
+ brackets.missingCloseBrackets > 0 ||
290
+ brackets.missingCloseBraces > 0
291
+ ) {
292
+ return true;
293
+ }
294
+
295
+ const abruptEndings = [
296
+ /,\s*$/,
297
+ /:\s*$/,
298
+ /"\s*:\s*$/,
299
+ /\[\s*$/,
300
+ /{\s*$/,
301
+ ];
302
+
303
+ for (const pattern of abruptEndings) {
304
+ if (pattern.test(trimmed)) return true;
305
+ }
306
+
307
+ return false;
308
+ }