@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.
- package/LICENSE +72 -0
- package/dist/chunk-KH4JNOLT.js +174 -0
- package/dist/chunk-KH4JNOLT.js.map +1 -0
- package/dist/chunk-MJS33AAS.js +234 -0
- package/dist/chunk-MJS33AAS.js.map +1 -0
- package/dist/chunk-PV3G5PJS.js +633 -0
- package/dist/chunk-PV3G5PJS.js.map +1 -0
- package/dist/chunk-WM7QVK2Z.js +192 -0
- package/dist/chunk-WM7QVK2Z.js.map +1 -0
- package/dist/client.d.ts +136 -0
- package/dist/client.js +39 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +477 -0
- package/dist/index.js.map +1 -0
- package/dist/json-parser.d.ts +43 -0
- package/dist/json-parser.js +15 -0
- package/dist/json-parser.js.map +1 -0
- package/dist/rate-limiter-9XAWfHwe.d.ts +98 -0
- package/dist/structured-output.d.ts +113 -0
- package/dist/structured-output.js +16 -0
- package/dist/structured-output.js.map +1 -0
- package/package.json +55 -0
- package/src/client.ts +967 -0
- package/src/continuation.ts +290 -0
- package/src/index.ts +87 -0
- package/src/json-parser.ts +273 -0
- package/src/rate-limiter.ts +237 -0
- package/src/structured-output.ts +330 -0
- package/src/token-tracker.ts +116 -0
- package/src/truncation-detector.ts +308 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Continuation Utility
|
|
3
|
+
*
|
|
4
|
+
* Handles truncated LLM responses with automatic continuation.
|
|
5
|
+
* - Detects truncation via finish_reason and JSON structure
|
|
6
|
+
* - Automatically continues with full context
|
|
7
|
+
* - Merges partial and continuation responses
|
|
8
|
+
* - Salvages partial data if max continuations reached
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { LLMClient, type LLMFinishReason } from './client.js';
|
|
15
|
+
import { detectTruncation } from './truncation-detector.js';
|
|
16
|
+
import { extractJsonFromText, autoCloseJson, isValidJson } from './json-parser.js';
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export interface ContinuationOptions<T> {
|
|
23
|
+
client: LLMClient;
|
|
24
|
+
systemPrompt: string;
|
|
25
|
+
userPrompt: string;
|
|
26
|
+
schema?: z.ZodSchema<T>;
|
|
27
|
+
maxTokens?: number;
|
|
28
|
+
maxContinuations?: number;
|
|
29
|
+
maxRetries?: number;
|
|
30
|
+
buildContinuationPrompt: (
|
|
31
|
+
partialResponse: string,
|
|
32
|
+
attempt: number,
|
|
33
|
+
) => string;
|
|
34
|
+
continuationSystemPrompt?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ContinuationResult<T> {
|
|
38
|
+
data: T;
|
|
39
|
+
raw: string;
|
|
40
|
+
continuationCount: number;
|
|
41
|
+
warnings: string[];
|
|
42
|
+
wasSalvaged: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Constants
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
const DEFAULT_MAX_TOKENS = 8192;
|
|
50
|
+
const DEFAULT_MAX_CONTINUATIONS = 3;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default continuation system prompt.
|
|
54
|
+
* Used when no custom continuationSystemPrompt is provided.
|
|
55
|
+
*/
|
|
56
|
+
const DEFAULT_CONTINUATION_SYSTEM_PROMPT = `You are a JSON continuation assistant. Your ONLY job is to continue generating JSON from where the previous response was truncated.
|
|
57
|
+
|
|
58
|
+
Rules:
|
|
59
|
+
1. Continue from EXACTLY where the previous output stopped
|
|
60
|
+
2. Do NOT repeat any content already generated
|
|
61
|
+
3. Complete the JSON structure properly with all closing brackets
|
|
62
|
+
4. Do NOT wrap in markdown code blocks
|
|
63
|
+
5. Output ONLY the continuation JSON, nothing else`;
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Helper Functions
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
export function mergeResponses(
|
|
70
|
+
previous: string,
|
|
71
|
+
continuation: string,
|
|
72
|
+
): string {
|
|
73
|
+
const trimmedPrev = previous.trimEnd();
|
|
74
|
+
const trimmedCont = continuation.trimStart();
|
|
75
|
+
|
|
76
|
+
let cleanedCont = trimmedCont
|
|
77
|
+
.replace(/^```json?\s*/i, '')
|
|
78
|
+
.replace(/```\s*$/i, '')
|
|
79
|
+
.trim();
|
|
80
|
+
|
|
81
|
+
if (cleanedCont.startsWith('{')) {
|
|
82
|
+
try {
|
|
83
|
+
const contParsed = JSON.parse(autoCloseJson(cleanedCont));
|
|
84
|
+
const keys = Object.keys(contParsed);
|
|
85
|
+
if (keys.length === 1 && Array.isArray(contParsed[keys[0]])) {
|
|
86
|
+
cleanedCont = contParsed[keys[0]]
|
|
87
|
+
.map((item: unknown) => JSON.stringify(item))
|
|
88
|
+
.join(',\n');
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Continue with original cleaning
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (cleanedCont.startsWith('}') || cleanedCont.startsWith(']')) {
|
|
96
|
+
return trimmedPrev + cleanedCont;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const prevEndsWithValue = /[\}\]\"\d]$/.test(trimmedPrev);
|
|
100
|
+
const contStartsWithValue = /^[\{\[\"]/.test(cleanedCont);
|
|
101
|
+
|
|
102
|
+
if (prevEndsWithValue && contStartsWithValue) {
|
|
103
|
+
return trimmedPrev + ',\n' + cleanedCont;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return trimmedPrev + cleanedCont;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function salvagePartialResponse<T>(rawResponse: string): T | null {
|
|
110
|
+
console.warn('[Continuation] Attempting to salvage partial response');
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const cleanedResponse = extractJsonFromText(rawResponse) || rawResponse;
|
|
114
|
+
const closed = autoCloseJson(cleanedResponse);
|
|
115
|
+
const parsed = JSON.parse(closed) as T;
|
|
116
|
+
console.log('[Continuation] Successfully salvaged partial response');
|
|
117
|
+
return parsed;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error('[Continuation] Could not salvage response:', error);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Main Function
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
export async function callWithContinuation<T>(
|
|
130
|
+
options: ContinuationOptions<T>,
|
|
131
|
+
): Promise<ContinuationResult<T>> {
|
|
132
|
+
const {
|
|
133
|
+
client,
|
|
134
|
+
systemPrompt,
|
|
135
|
+
userPrompt,
|
|
136
|
+
schema,
|
|
137
|
+
maxTokens = DEFAULT_MAX_TOKENS,
|
|
138
|
+
maxContinuations = DEFAULT_MAX_CONTINUATIONS,
|
|
139
|
+
buildContinuationPrompt,
|
|
140
|
+
continuationSystemPrompt = DEFAULT_CONTINUATION_SYSTEM_PROMPT,
|
|
141
|
+
} = options;
|
|
142
|
+
|
|
143
|
+
let rawResponse = '';
|
|
144
|
+
let continuationCount = 0;
|
|
145
|
+
const warnings: string[] = [];
|
|
146
|
+
let wasSalvaged = false;
|
|
147
|
+
|
|
148
|
+
console.log('[Continuation] Starting LLM call with continuation support');
|
|
149
|
+
console.log(
|
|
150
|
+
`[Continuation] Max tokens: ${maxTokens}, Max continuations: ${maxContinuations}`,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const response = await client.callRawWithMetadata({
|
|
155
|
+
systemPrompt,
|
|
156
|
+
userPrompt,
|
|
157
|
+
maxTokens,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
rawResponse = extractJsonFromText(response.raw) || response.raw;
|
|
161
|
+
|
|
162
|
+
console.log(
|
|
163
|
+
`[Continuation] Initial response: ${rawResponse.length} chars, finish_reason: ${response.finishReason}`,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
let truncation = detectTruncation(rawResponse, response.finishReason);
|
|
167
|
+
|
|
168
|
+
while (truncation.isTruncated && continuationCount < maxContinuations) {
|
|
169
|
+
continuationCount++;
|
|
170
|
+
const warningMsg = `Response truncated (${truncation.reason}), continuing (attempt ${continuationCount}/${maxContinuations})`;
|
|
171
|
+
console.log(`[Continuation] ${warningMsg}`);
|
|
172
|
+
warnings.push(warningMsg);
|
|
173
|
+
|
|
174
|
+
const contPrompt = buildContinuationPrompt(
|
|
175
|
+
rawResponse,
|
|
176
|
+
continuationCount,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const contResponse = await client.callRawWithMetadata({
|
|
180
|
+
systemPrompt: continuationSystemPrompt,
|
|
181
|
+
userPrompt: contPrompt,
|
|
182
|
+
maxTokens,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
console.log(
|
|
186
|
+
`[Continuation] Continuation response: ${contResponse.raw.length} chars, finish_reason: ${contResponse.finishReason}`,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const cleanedContResponse =
|
|
190
|
+
extractJsonFromText(contResponse.raw) || contResponse.raw;
|
|
191
|
+
rawResponse = mergeResponses(rawResponse, cleanedContResponse);
|
|
192
|
+
|
|
193
|
+
truncation = detectTruncation(rawResponse, contResponse.finishReason);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
continuationCount >= maxContinuations &&
|
|
198
|
+
truncation.isTruncated
|
|
199
|
+
) {
|
|
200
|
+
console.warn(
|
|
201
|
+
`[Continuation] Reached max continuations (${maxContinuations}), attempting to salvage...`,
|
|
202
|
+
);
|
|
203
|
+
warnings.push(
|
|
204
|
+
`Reached max continuations - some content may be incomplete`,
|
|
205
|
+
);
|
|
206
|
+
wasSalvaged = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const cleanedResponse =
|
|
210
|
+
extractJsonFromText(rawResponse) || rawResponse;
|
|
211
|
+
let data: T;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
if (isValidJson(cleanedResponse)) {
|
|
215
|
+
data = JSON.parse(cleanedResponse) as T;
|
|
216
|
+
} else {
|
|
217
|
+
const closed = autoCloseJson(cleanedResponse);
|
|
218
|
+
data = JSON.parse(closed) as T;
|
|
219
|
+
if (!wasSalvaged) {
|
|
220
|
+
warnings.push('Response required auto-closing of JSON brackets');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (parseError) {
|
|
224
|
+
const salvaged = salvagePartialResponse<T>(cleanedResponse);
|
|
225
|
+
if (salvaged) {
|
|
226
|
+
data = salvaged;
|
|
227
|
+
wasSalvaged = true;
|
|
228
|
+
warnings.push('Response was salvaged from partial data');
|
|
229
|
+
} else {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Failed to parse response after ${continuationCount} continuations: ${parseError}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (schema) {
|
|
237
|
+
try {
|
|
238
|
+
data = schema.parse(data);
|
|
239
|
+
} catch (validationError) {
|
|
240
|
+
console.warn(
|
|
241
|
+
'[Continuation] Schema validation failed:',
|
|
242
|
+
validationError,
|
|
243
|
+
);
|
|
244
|
+
warnings.push(`Schema validation issue: ${validationError}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log(
|
|
249
|
+
`[Continuation] Complete. Continuations: ${continuationCount}, Warnings: ${warnings.length}`,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
data,
|
|
254
|
+
raw: rawResponse,
|
|
255
|
+
continuationCount,
|
|
256
|
+
warnings,
|
|
257
|
+
wasSalvaged,
|
|
258
|
+
};
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error('[Continuation] Error during LLM call:', error);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function buildGenericContinuationPrompt(
|
|
266
|
+
context: string,
|
|
267
|
+
partialResponse: string,
|
|
268
|
+
attempt: number,
|
|
269
|
+
maxAttempts: number = DEFAULT_MAX_CONTINUATIONS,
|
|
270
|
+
): string {
|
|
271
|
+
return `## CONTINUATION REQUEST (Attempt ${attempt}/${maxAttempts})
|
|
272
|
+
|
|
273
|
+
Your previous response was truncated. Continue generating from where you left off.
|
|
274
|
+
|
|
275
|
+
### ORIGINAL CONTEXT
|
|
276
|
+
${context}
|
|
277
|
+
|
|
278
|
+
### WHAT YOU GENERATED SO FAR
|
|
279
|
+
\`\`\`json
|
|
280
|
+
${partialResponse}
|
|
281
|
+
\`\`\`
|
|
282
|
+
|
|
283
|
+
### INSTRUCTIONS
|
|
284
|
+
1. Continue from EXACTLY where the response was cut off
|
|
285
|
+
2. Do NOT repeat any content already generated
|
|
286
|
+
3. Complete the JSON structure properly
|
|
287
|
+
4. Do NOT wrap your response in markdown code blocks
|
|
288
|
+
|
|
289
|
+
Continue generating now:`;
|
|
290
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @almadar/llm
|
|
3
|
+
*
|
|
4
|
+
* Multi-provider LLM client with rate limiting, token tracking,
|
|
5
|
+
* structured outputs, and continuation handling.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
LLMClient,
|
|
12
|
+
getSharedLLMClient,
|
|
13
|
+
resetSharedLLMClient,
|
|
14
|
+
createRequirementsClient,
|
|
15
|
+
createCreativeClient,
|
|
16
|
+
createFixClient,
|
|
17
|
+
createDeepSeekClient,
|
|
18
|
+
createOpenAIClient,
|
|
19
|
+
createAnthropicClient,
|
|
20
|
+
createKimiClient,
|
|
21
|
+
getAvailableProvider,
|
|
22
|
+
isProviderAvailable,
|
|
23
|
+
DEEPSEEK_MODELS,
|
|
24
|
+
OPENAI_MODELS,
|
|
25
|
+
ANTHROPIC_MODELS,
|
|
26
|
+
KIMI_MODELS,
|
|
27
|
+
type LLMProvider,
|
|
28
|
+
type ProviderConfig,
|
|
29
|
+
type LLMClientOptions,
|
|
30
|
+
type LLMCallOptions,
|
|
31
|
+
type LLMResponse,
|
|
32
|
+
type LLMUsage,
|
|
33
|
+
type LLMFinishReason,
|
|
34
|
+
type CacheableBlock,
|
|
35
|
+
type CacheAwareLLMCallOptions,
|
|
36
|
+
} from './client.js';
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
RateLimiter,
|
|
40
|
+
getGlobalRateLimiter,
|
|
41
|
+
resetGlobalRateLimiter,
|
|
42
|
+
type RateLimiterOptions,
|
|
43
|
+
} from './rate-limiter.js';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
TokenTracker,
|
|
47
|
+
getGlobalTokenTracker,
|
|
48
|
+
resetGlobalTokenTracker,
|
|
49
|
+
type TokenUsage,
|
|
50
|
+
} from './token-tracker.js';
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
parseJsonResponse,
|
|
54
|
+
extractJsonFromText,
|
|
55
|
+
safeParseJson,
|
|
56
|
+
isValidJson,
|
|
57
|
+
autoCloseJson,
|
|
58
|
+
} from './json-parser.js';
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
detectTruncation,
|
|
62
|
+
findLastCompleteElement,
|
|
63
|
+
isLikelyTruncated,
|
|
64
|
+
type TruncationResult,
|
|
65
|
+
type TruncationReason,
|
|
66
|
+
} from './truncation-detector.js';
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
callWithContinuation,
|
|
70
|
+
mergeResponses,
|
|
71
|
+
salvagePartialResponse,
|
|
72
|
+
buildGenericContinuationPrompt,
|
|
73
|
+
type ContinuationOptions,
|
|
74
|
+
type ContinuationResult,
|
|
75
|
+
} from './continuation.js';
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
StructuredOutputClient,
|
|
79
|
+
getStructuredOutputClient,
|
|
80
|
+
resetStructuredOutputClient,
|
|
81
|
+
isStructuredOutputAvailable,
|
|
82
|
+
STRUCTURED_OUTPUT_MODELS,
|
|
83
|
+
type StructuredOutputOptions,
|
|
84
|
+
type StructuredGenerationOptions,
|
|
85
|
+
type StructuredGenerationResult,
|
|
86
|
+
type JsonSchema,
|
|
87
|
+
} from './structured-output.js';
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Parser Utilities
|
|
3
|
+
*
|
|
4
|
+
* Robust JSON parsing for LLM responses that may contain:
|
|
5
|
+
* - Markdown code blocks
|
|
6
|
+
* - Extra text before/after JSON
|
|
7
|
+
* - Minor formatting issues
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
function extractBalancedBrackets(
|
|
15
|
+
text: string,
|
|
16
|
+
startIdx: number,
|
|
17
|
+
openBracket: string,
|
|
18
|
+
closeBracket: string,
|
|
19
|
+
): string | null {
|
|
20
|
+
if (text[startIdx] !== openBracket) return null;
|
|
21
|
+
|
|
22
|
+
let depth = 0;
|
|
23
|
+
let inString = false;
|
|
24
|
+
let escapeNext = false;
|
|
25
|
+
|
|
26
|
+
for (let i = startIdx; i < text.length; i++) {
|
|
27
|
+
const char = text[i];
|
|
28
|
+
|
|
29
|
+
if (escapeNext) {
|
|
30
|
+
escapeNext = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (char === '\\' && inString) {
|
|
35
|
+
escapeNext = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (char === '"') {
|
|
40
|
+
inString = !inString;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (inString) continue;
|
|
45
|
+
|
|
46
|
+
if (char === openBracket) {
|
|
47
|
+
depth++;
|
|
48
|
+
} else if (char === closeBracket) {
|
|
49
|
+
depth--;
|
|
50
|
+
if (depth === 0) {
|
|
51
|
+
return text.substring(startIdx, i + 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract JSON from LLM response text.
|
|
61
|
+
*
|
|
62
|
+
* Handles markdown code blocks, raw JSON objects/arrays, and primitive values.
|
|
63
|
+
*/
|
|
64
|
+
export function extractJsonFromText(text: string): string | null {
|
|
65
|
+
const trimmed = text.trim();
|
|
66
|
+
|
|
67
|
+
// Try markdown code blocks first
|
|
68
|
+
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
69
|
+
if (codeBlockMatch) {
|
|
70
|
+
return codeBlockMatch[1].trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const objectStartIdx = trimmed.indexOf('{');
|
|
74
|
+
const arrayStartIdx = trimmed.indexOf('[');
|
|
75
|
+
|
|
76
|
+
const objectFirst =
|
|
77
|
+
objectStartIdx !== -1 &&
|
|
78
|
+
(arrayStartIdx === -1 || objectStartIdx < arrayStartIdx);
|
|
79
|
+
const arrayFirst =
|
|
80
|
+
arrayStartIdx !== -1 &&
|
|
81
|
+
(objectStartIdx === -1 || arrayStartIdx < objectStartIdx);
|
|
82
|
+
|
|
83
|
+
if (arrayFirst) {
|
|
84
|
+
const arrayJson = extractBalancedBrackets(
|
|
85
|
+
trimmed,
|
|
86
|
+
arrayStartIdx,
|
|
87
|
+
'[',
|
|
88
|
+
']',
|
|
89
|
+
);
|
|
90
|
+
if (arrayJson) return arrayJson;
|
|
91
|
+
const arrayMatch = trimmed.match(/\[[\s\S]*\]/);
|
|
92
|
+
if (arrayMatch) return arrayMatch[0];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (objectFirst) {
|
|
96
|
+
const objectJson = extractBalancedBrackets(
|
|
97
|
+
trimmed,
|
|
98
|
+
objectStartIdx,
|
|
99
|
+
'{',
|
|
100
|
+
'}',
|
|
101
|
+
);
|
|
102
|
+
if (objectJson) return objectJson;
|
|
103
|
+
const objectMatch = trimmed.match(/\{[\s\S]*\}/);
|
|
104
|
+
if (objectMatch) return objectMatch[0];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Primitive JSON values
|
|
108
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed;
|
|
109
|
+
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(trimmed)) return trimmed;
|
|
110
|
+
if (trimmed === 'true' || trimmed === 'false') return trimmed;
|
|
111
|
+
if (trimmed === 'null') return trimmed;
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse JSON from LLM response with optional Zod schema validation.
|
|
118
|
+
*/
|
|
119
|
+
export function parseJsonResponse<T>(
|
|
120
|
+
response: string,
|
|
121
|
+
schema?: z.ZodSchema<T>,
|
|
122
|
+
): T {
|
|
123
|
+
const jsonStr = extractJsonFromText(response);
|
|
124
|
+
|
|
125
|
+
if (!jsonStr) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'No valid JSON found in response. ' +
|
|
128
|
+
'Expected a JSON value (object, array, string, number, boolean, or null), ' +
|
|
129
|
+
'possibly wrapped in markdown code blocks.',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let parsed: unknown;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(jsonStr);
|
|
136
|
+
} catch (parseError) {
|
|
137
|
+
const fixed = fixCommonJsonIssues(jsonStr);
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(fixed);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Failed to parse JSON: ${parseError instanceof Error ? parseError.message : 'Unknown error'}. ` +
|
|
143
|
+
`Raw text: ${jsonStr.substring(0, 200)}...`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (schema) {
|
|
149
|
+
const result = schema.safeParse(parsed);
|
|
150
|
+
if (!result.success) {
|
|
151
|
+
const errors = result.error.errors
|
|
152
|
+
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
|
153
|
+
.join('; ');
|
|
154
|
+
throw new Error(`Schema validation failed: ${errors}`);
|
|
155
|
+
}
|
|
156
|
+
return result.data;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return parsed as T;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fixCommonJsonIssues(json: string): string {
|
|
163
|
+
let fixed = json;
|
|
164
|
+
fixed = fixed.replace(/,(\s*[}\]])/g, '$1');
|
|
165
|
+
fixed = fixed.replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3');
|
|
166
|
+
fixed = fixed.replace(/'/g, '"');
|
|
167
|
+
fixed = fixed.replace(/[\x00-\x1F\x7F]/g, ' ');
|
|
168
|
+
return fixed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Safely parse JSON without throwing.
|
|
173
|
+
*/
|
|
174
|
+
export function safeParseJson<T>(
|
|
175
|
+
response: string,
|
|
176
|
+
schema?: z.ZodSchema<T>,
|
|
177
|
+
): { success: true; data: T } | { success: false; error: Error } {
|
|
178
|
+
try {
|
|
179
|
+
const data = parseJsonResponse(response, schema);
|
|
180
|
+
return { success: true, data };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
success: false,
|
|
184
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Check if a string is valid JSON.
|
|
191
|
+
*/
|
|
192
|
+
export function isValidJson(str: string): boolean {
|
|
193
|
+
try {
|
|
194
|
+
JSON.parse(str);
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Attempt to auto-close unclosed JSON brackets.
|
|
203
|
+
*/
|
|
204
|
+
export function autoCloseJson(json: string): string {
|
|
205
|
+
let result = json.trim();
|
|
206
|
+
|
|
207
|
+
// Handle unclosed strings
|
|
208
|
+
let inString = false;
|
|
209
|
+
let escaped = false;
|
|
210
|
+
for (const char of result) {
|
|
211
|
+
if (escaped) {
|
|
212
|
+
escaped = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (char === '\\') {
|
|
216
|
+
escaped = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (char === '"') {
|
|
220
|
+
inString = !inString;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (inString) {
|
|
224
|
+
result += '"';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Remove trailing incomplete content
|
|
228
|
+
result = result.replace(/,\s*$/, '');
|
|
229
|
+
result = result.replace(/:\s*$/, ': null');
|
|
230
|
+
|
|
231
|
+
// Build correct closing sequence
|
|
232
|
+
const closers = buildClosingSequence(result);
|
|
233
|
+
result += closers;
|
|
234
|
+
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildClosingSequence(json: string): string {
|
|
239
|
+
const stack: string[] = [];
|
|
240
|
+
let inString = false;
|
|
241
|
+
let escaped = false;
|
|
242
|
+
|
|
243
|
+
for (const char of json) {
|
|
244
|
+
if (escaped) {
|
|
245
|
+
escaped = false;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (char === '\\' && inString) {
|
|
250
|
+
escaped = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (char === '"') {
|
|
255
|
+
inString = !inString;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (inString) continue;
|
|
260
|
+
|
|
261
|
+
if (char === '[') {
|
|
262
|
+
stack.push(']');
|
|
263
|
+
} else if (char === '{') {
|
|
264
|
+
stack.push('}');
|
|
265
|
+
} else if (char === ']' || char === '}') {
|
|
266
|
+
if (stack.length > 0 && stack[stack.length - 1] === char) {
|
|
267
|
+
stack.pop();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return stack.reverse().join('');
|
|
273
|
+
}
|