@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,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
+ }