llm-fns 1.0.2
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 +21 -0
- package/package.json +27 -0
- package/readme.md +299 -0
- package/scripts/release.sh +32 -0
- package/src/createLlmClient.spec.ts +42 -0
- package/src/createLlmClient.ts +389 -0
- package/src/createLlmRetryClient.ts +244 -0
- package/src/createZodLlmClient.spec.ts +76 -0
- package/src/createZodLlmClient.ts +378 -0
- package/src/index.ts +5 -0
- package/src/llmFactory.ts +26 -0
- package/src/retryUtils.ts +91 -0
- package/tests/basic.test.ts +47 -0
- package/tests/env.ts +16 -0
- package/tests/setup.ts +24 -0
- package/tests/zod.test.ts +178 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { normalizeZodArgs } from './createZodLlmClient.js';
|
|
4
|
+
|
|
5
|
+
describe('normalizeZodArgs', () => {
|
|
6
|
+
const TestSchema = z.object({
|
|
7
|
+
foo: z.string()
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should normalize Schema Only (Case 1)', () => {
|
|
11
|
+
const result = normalizeZodArgs(TestSchema);
|
|
12
|
+
|
|
13
|
+
expect(result.mainInstruction).toContain('Generate a valid JSON');
|
|
14
|
+
expect(result.userMessagePayload).toBe('Generate the data.');
|
|
15
|
+
expect(result.dataExtractionSchema).toBe(TestSchema);
|
|
16
|
+
expect(result.options).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should normalize Schema Only with Options (Case 1)', () => {
|
|
20
|
+
const options = { temperature: 0.5 };
|
|
21
|
+
const result = normalizeZodArgs(TestSchema, options);
|
|
22
|
+
|
|
23
|
+
expect(result.mainInstruction).toContain('Generate a valid JSON');
|
|
24
|
+
expect(result.userMessagePayload).toBe('Generate the data.');
|
|
25
|
+
expect(result.dataExtractionSchema).toBe(TestSchema);
|
|
26
|
+
expect(result.options).toBe(options);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should normalize Prompt + Schema (Case 2)', () => {
|
|
30
|
+
const prompt = "Extract data";
|
|
31
|
+
const result = normalizeZodArgs(prompt, TestSchema);
|
|
32
|
+
|
|
33
|
+
expect(result.mainInstruction).toContain('You are a helpful assistant');
|
|
34
|
+
expect(result.userMessagePayload).toBe(prompt);
|
|
35
|
+
expect(result.dataExtractionSchema).toBe(TestSchema);
|
|
36
|
+
expect(result.options).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should normalize Prompt + Schema with Options (Case 2)', () => {
|
|
40
|
+
const prompt = "Extract data";
|
|
41
|
+
const options = { temperature: 0.5 };
|
|
42
|
+
const result = normalizeZodArgs(prompt, TestSchema, options);
|
|
43
|
+
|
|
44
|
+
expect(result.mainInstruction).toContain('You are a helpful assistant');
|
|
45
|
+
expect(result.userMessagePayload).toBe(prompt);
|
|
46
|
+
expect(result.dataExtractionSchema).toBe(TestSchema);
|
|
47
|
+
expect(result.options).toBe(options);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should normalize System + User + Schema (Case 3)', () => {
|
|
51
|
+
const system = "System prompt";
|
|
52
|
+
const user = "User prompt";
|
|
53
|
+
const result = normalizeZodArgs(system, user, TestSchema);
|
|
54
|
+
|
|
55
|
+
expect(result.mainInstruction).toBe(system);
|
|
56
|
+
expect(result.userMessagePayload).toBe(user);
|
|
57
|
+
expect(result.dataExtractionSchema).toBe(TestSchema);
|
|
58
|
+
expect(result.options).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should normalize System + User + Schema with Options (Case 3)', () => {
|
|
62
|
+
const system = "System prompt";
|
|
63
|
+
const user = "User prompt";
|
|
64
|
+
const options = { temperature: 0.5 };
|
|
65
|
+
const result = normalizeZodArgs(system, user, TestSchema, options);
|
|
66
|
+
|
|
67
|
+
expect(result.mainInstruction).toBe(system);
|
|
68
|
+
expect(result.userMessagePayload).toBe(user);
|
|
69
|
+
expect(result.dataExtractionSchema).toBe(TestSchema);
|
|
70
|
+
expect(result.options).toBe(options);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should throw error for invalid arguments', () => {
|
|
74
|
+
expect(() => normalizeZodArgs({} as any)).toThrow('Invalid arguments');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import OpenAI from 'openai'; // Import the OpenAI library
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { PromptFunction, LlmPromptOptions, OpenRouterResponseFormat, IsPromptCachedFunction } from "./createLlmClient.js";
|
|
4
|
+
import { createLlmRetryClient, LlmRetryError, LlmRetryOptions } from "./createLlmRetryClient.js";
|
|
5
|
+
import { ZodError, ZodTypeAny } from "zod";
|
|
6
|
+
|
|
7
|
+
export type ZodLlmClientOptions = Omit<LlmPromptOptions, 'messages' | 'response_format'> & {
|
|
8
|
+
maxRetries?: number;
|
|
9
|
+
/**
|
|
10
|
+
* If true, passes `response_format: { type: 'json_object' }` to the model.
|
|
11
|
+
* If false, only includes the schema in the system prompt.
|
|
12
|
+
* Defaults to true.
|
|
13
|
+
*/
|
|
14
|
+
useResponseFormat?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* A hook to process the parsed JSON data before it is validated against the Zod schema.
|
|
17
|
+
* This can be used to merge partial results or perform other transformations.
|
|
18
|
+
* @param data The parsed JSON data from the LLM response.
|
|
19
|
+
* @returns The processed data to be validated.
|
|
20
|
+
*/
|
|
21
|
+
beforeValidation?: (data: any) => any;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CreateZodLlmClientParams {
|
|
25
|
+
prompt: PromptFunction;
|
|
26
|
+
isPromptCached: IsPromptCachedFunction;
|
|
27
|
+
fallbackPrompt?: PromptFunction;
|
|
28
|
+
disableJsonFixer?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isZodSchema(obj: any): obj is ZodTypeAny {
|
|
32
|
+
return (
|
|
33
|
+
typeof obj === 'object' &&
|
|
34
|
+
obj !== null &&
|
|
35
|
+
'parse' in obj &&
|
|
36
|
+
'_def' in obj
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface NormalizedZodArgs<T extends ZodTypeAny> {
|
|
41
|
+
mainInstruction: string;
|
|
42
|
+
userMessagePayload: string | OpenAI.Chat.Completions.ChatCompletionContentPart[];
|
|
43
|
+
dataExtractionSchema: T;
|
|
44
|
+
options?: ZodLlmClientOptions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeZodArgs<T extends ZodTypeAny>(
|
|
48
|
+
arg1: string | T,
|
|
49
|
+
arg2?: string | OpenAI.Chat.Completions.ChatCompletionContentPart[] | T | ZodLlmClientOptions,
|
|
50
|
+
arg3?: T | ZodLlmClientOptions,
|
|
51
|
+
arg4?: ZodLlmClientOptions
|
|
52
|
+
): NormalizedZodArgs<T> {
|
|
53
|
+
if (isZodSchema(arg1)) {
|
|
54
|
+
// Case 1: promptZod(schema, options?)
|
|
55
|
+
return {
|
|
56
|
+
mainInstruction: "Generate a valid JSON object based on the schema.",
|
|
57
|
+
userMessagePayload: "Generate the data.",
|
|
58
|
+
dataExtractionSchema: arg1,
|
|
59
|
+
options: arg2 as ZodLlmClientOptions | undefined
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof arg1 === 'string') {
|
|
64
|
+
if (isZodSchema(arg2)) {
|
|
65
|
+
// Case 2: promptZod(prompt, schema, options?)
|
|
66
|
+
return {
|
|
67
|
+
mainInstruction: "You are a helpful assistant that outputs JSON matching the provided schema.",
|
|
68
|
+
userMessagePayload: arg1,
|
|
69
|
+
dataExtractionSchema: arg2 as T,
|
|
70
|
+
options: arg3 as ZodLlmClientOptions | undefined
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Case 3: promptZod(system, user, schema, options?)
|
|
75
|
+
return {
|
|
76
|
+
mainInstruction: arg1,
|
|
77
|
+
userMessagePayload: arg2 as string | OpenAI.Chat.Completions.ChatCompletionContentPart[],
|
|
78
|
+
dataExtractionSchema: arg3 as T,
|
|
79
|
+
options: arg4
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error("Invalid arguments passed to promptZod");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createZodLlmClient(params: CreateZodLlmClientParams) {
|
|
87
|
+
const { prompt, isPromptCached, fallbackPrompt, disableJsonFixer = false } = params;
|
|
88
|
+
const llmRetryClient = createLlmRetryClient({ prompt, fallbackPrompt });
|
|
89
|
+
|
|
90
|
+
async function _tryToFixJson(
|
|
91
|
+
brokenResponse: string,
|
|
92
|
+
schemaJsonString: string,
|
|
93
|
+
errorDetails: string,
|
|
94
|
+
options?: ZodLlmClientOptions
|
|
95
|
+
): Promise<string | null> {
|
|
96
|
+
const fixupPrompt = `
|
|
97
|
+
An attempt to generate a JSON object resulted in the following output, which is either not valid JSON or does not conform to the required schema.
|
|
98
|
+
|
|
99
|
+
Your task is to act as a JSON fixer. Analyze the provided "BROKEN RESPONSE" and correct it to match the "REQUIRED JSON SCHEMA".
|
|
100
|
+
|
|
101
|
+
- If the broken response contains all the necessary information to create a valid JSON object according to the schema, please provide the corrected, valid JSON object.
|
|
102
|
+
- If the broken response is missing essential information, or is too garbled to be fixed, please respond with the exact string: "CANNOT_FIX".
|
|
103
|
+
- Your response must be ONLY the corrected JSON object or the string "CANNOT_FIX". Do not include any other text, explanations, or markdown formatting.
|
|
104
|
+
|
|
105
|
+
REQUIRED JSON SCHEMA:
|
|
106
|
+
${schemaJsonString}
|
|
107
|
+
|
|
108
|
+
ERROR DETAILS:
|
|
109
|
+
${errorDetails}
|
|
110
|
+
|
|
111
|
+
BROKEN RESPONSE:
|
|
112
|
+
${brokenResponse}
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
|
116
|
+
{ role: 'system', content: 'You are an expert at fixing malformed JSON data to match a specific schema.' },
|
|
117
|
+
{ role: 'user', content: fixupPrompt }
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const useResponseFormat = options?.useResponseFormat ?? true;
|
|
121
|
+
const response_format: OpenRouterResponseFormat | undefined = useResponseFormat
|
|
122
|
+
? { type: 'json_object' }
|
|
123
|
+
: undefined;
|
|
124
|
+
|
|
125
|
+
const { maxRetries, useResponseFormat: _useResponseFormat, ...restOptions } = options || {};
|
|
126
|
+
|
|
127
|
+
const completion = await prompt({
|
|
128
|
+
messages,
|
|
129
|
+
response_format,
|
|
130
|
+
...restOptions
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const fixedResponse = completion.choices[0]?.message?.content;
|
|
134
|
+
|
|
135
|
+
if (fixedResponse && fixedResponse.trim() === 'CANNOT_FIX') {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return fixedResponse || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async function _parseOrFixJson(
|
|
144
|
+
llmResponseString: string,
|
|
145
|
+
schemaJsonString: string,
|
|
146
|
+
options: ZodLlmClientOptions | undefined
|
|
147
|
+
): Promise<any> {
|
|
148
|
+
let jsonDataToParse: string = llmResponseString.trim();
|
|
149
|
+
|
|
150
|
+
// Robust handling for responses wrapped in markdown code blocks
|
|
151
|
+
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/;
|
|
152
|
+
const match = codeBlockRegex.exec(jsonDataToParse);
|
|
153
|
+
if (match && match[1]) {
|
|
154
|
+
jsonDataToParse = match[1].trim();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (jsonDataToParse === "") {
|
|
158
|
+
throw new Error("LLM returned an empty string.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(jsonDataToParse);
|
|
163
|
+
} catch (parseError: any) {
|
|
164
|
+
if (disableJsonFixer) {
|
|
165
|
+
throw parseError; // re-throw original error
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Attempt a one-time fix before failing.
|
|
169
|
+
const errorDetails = `JSON Parse Error: ${parseError.message}`;
|
|
170
|
+
const fixedResponse = await _tryToFixJson(jsonDataToParse, schemaJsonString, errorDetails, options);
|
|
171
|
+
|
|
172
|
+
if (fixedResponse) {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(fixedResponse);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// Fix-up failed, throw original error.
|
|
177
|
+
throw parseError;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throw parseError; // if no fixed response
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function _validateOrFixSchema<SchemaType extends ZodTypeAny>(
|
|
186
|
+
jsonData: any,
|
|
187
|
+
dataExtractionSchema: SchemaType,
|
|
188
|
+
schemaJsonString: string,
|
|
189
|
+
options: ZodLlmClientOptions | undefined
|
|
190
|
+
): Promise<z.infer<SchemaType>> {
|
|
191
|
+
try {
|
|
192
|
+
if (options?.beforeValidation) {
|
|
193
|
+
jsonData = options.beforeValidation(jsonData);
|
|
194
|
+
}
|
|
195
|
+
return dataExtractionSchema.parse(jsonData);
|
|
196
|
+
} catch (validationError: any) {
|
|
197
|
+
if (!(validationError instanceof ZodError) || disableJsonFixer) {
|
|
198
|
+
throw validationError;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Attempt a one-time fix for schema validation errors.
|
|
202
|
+
const errorDetails = `Schema Validation Error: ${JSON.stringify(validationError.format(), null, 2)}`;
|
|
203
|
+
const fixedResponse = await _tryToFixJson(JSON.stringify(jsonData, null, 2), schemaJsonString, errorDetails, options);
|
|
204
|
+
|
|
205
|
+
if (fixedResponse) {
|
|
206
|
+
try {
|
|
207
|
+
let fixedJsonData = JSON.parse(fixedResponse);
|
|
208
|
+
if (options?.beforeValidation) {
|
|
209
|
+
fixedJsonData = options.beforeValidation(fixedJsonData);
|
|
210
|
+
}
|
|
211
|
+
return dataExtractionSchema.parse(fixedJsonData);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// Fix-up failed, throw original validation error
|
|
214
|
+
throw validationError;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
throw validationError; // if no fixed response
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function _getZodPromptConfig<T extends ZodTypeAny>(
|
|
223
|
+
mainInstruction: string,
|
|
224
|
+
dataExtractionSchema: T,
|
|
225
|
+
options?: ZodLlmClientOptions
|
|
226
|
+
) {
|
|
227
|
+
const schema = z.toJSONSchema(dataExtractionSchema, {
|
|
228
|
+
unrepresentable: 'any'
|
|
229
|
+
})
|
|
230
|
+
const schemaJsonString = JSON.stringify(schema);
|
|
231
|
+
|
|
232
|
+
const commonPromptFooter = `
|
|
233
|
+
Your response MUST be a single JSON entity (object or array) that strictly adheres to the following JSON schema.
|
|
234
|
+
Do NOT include any other text, explanations, or markdown formatting (like \`\`\`json) before or after the JSON entity.
|
|
235
|
+
|
|
236
|
+
JSON schema:
|
|
237
|
+
${schemaJsonString}`;
|
|
238
|
+
|
|
239
|
+
const finalMainInstruction = `${mainInstruction}\n${commonPromptFooter}`;
|
|
240
|
+
|
|
241
|
+
const useResponseFormat = options?.useResponseFormat ?? true;
|
|
242
|
+
const response_format: OpenRouterResponseFormat | undefined = useResponseFormat
|
|
243
|
+
? { type: 'json_object' }
|
|
244
|
+
: undefined;
|
|
245
|
+
|
|
246
|
+
return { finalMainInstruction, schemaJsonString, response_format };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function promptZod<T extends ZodTypeAny>(
|
|
250
|
+
schema: T,
|
|
251
|
+
options?: ZodLlmClientOptions
|
|
252
|
+
): Promise<z.infer<T>>;
|
|
253
|
+
async function promptZod<T extends ZodTypeAny>(
|
|
254
|
+
prompt: string,
|
|
255
|
+
schema: T,
|
|
256
|
+
options?: ZodLlmClientOptions
|
|
257
|
+
): Promise<z.infer<T>>;
|
|
258
|
+
async function promptZod<T extends ZodTypeAny>(
|
|
259
|
+
mainInstruction: string,
|
|
260
|
+
userMessagePayload: string | OpenAI.Chat.Completions.ChatCompletionContentPart[],
|
|
261
|
+
dataExtractionSchema: T,
|
|
262
|
+
options?: ZodLlmClientOptions
|
|
263
|
+
): Promise<z.infer<T>>;
|
|
264
|
+
async function promptZod<T extends ZodTypeAny>(
|
|
265
|
+
arg1: string | T,
|
|
266
|
+
arg2?: string | OpenAI.Chat.Completions.ChatCompletionContentPart[] | T | ZodLlmClientOptions,
|
|
267
|
+
arg3?: T | ZodLlmClientOptions,
|
|
268
|
+
arg4?: ZodLlmClientOptions
|
|
269
|
+
): Promise<z.infer<T>> {
|
|
270
|
+
const { mainInstruction, userMessagePayload, dataExtractionSchema, options } = normalizeZodArgs(arg1, arg2, arg3, arg4);
|
|
271
|
+
|
|
272
|
+
const { finalMainInstruction, schemaJsonString, response_format } = _getZodPromptConfig(
|
|
273
|
+
mainInstruction,
|
|
274
|
+
dataExtractionSchema,
|
|
275
|
+
options
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const processResponse = async (llmResponseString: string): Promise<z.infer<T>> => {
|
|
279
|
+
let jsonData: any;
|
|
280
|
+
try {
|
|
281
|
+
jsonData = await _parseOrFixJson(llmResponseString, schemaJsonString, options);
|
|
282
|
+
} catch (parseError: any) {
|
|
283
|
+
const errorMessage = `Your previous response resulted in an error.
|
|
284
|
+
Error Type: JSON_PARSE_ERROR
|
|
285
|
+
Error Details: ${parseError.message}
|
|
286
|
+
The response provided was not valid JSON. Please correct it.`;
|
|
287
|
+
throw new LlmRetryError(
|
|
288
|
+
errorMessage,
|
|
289
|
+
'JSON_PARSE_ERROR',
|
|
290
|
+
undefined,
|
|
291
|
+
llmResponseString
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const validatedData = await _validateOrFixSchema(jsonData, dataExtractionSchema, schemaJsonString, options);
|
|
297
|
+
return validatedData;
|
|
298
|
+
} catch (validationError: any) {
|
|
299
|
+
if (validationError instanceof ZodError) {
|
|
300
|
+
const rawResponseForError = JSON.stringify(jsonData, null, 2);
|
|
301
|
+
const errorDetails = JSON.stringify(validationError.format(), null, 2);
|
|
302
|
+
const errorMessage = `Your previous response resulted in an error.
|
|
303
|
+
Error Type: SCHEMA_VALIDATION_ERROR
|
|
304
|
+
Error Details: ${errorDetails}
|
|
305
|
+
The response was valid JSON but did not conform to the required schema. Please review the errors and the schema to provide a corrected response.`;
|
|
306
|
+
throw new LlmRetryError(
|
|
307
|
+
errorMessage,
|
|
308
|
+
'CUSTOM_ERROR',
|
|
309
|
+
validationError.format(),
|
|
310
|
+
rawResponseForError
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
// For other errors, rethrow and let LlmRetryClient handle as critical.
|
|
314
|
+
throw validationError;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
|
319
|
+
{ role: "system", content: finalMainInstruction },
|
|
320
|
+
{ role: "user", content: userMessagePayload }
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
const retryOptions: LlmRetryOptions<z.infer<T>> = {
|
|
324
|
+
...options,
|
|
325
|
+
messages,
|
|
326
|
+
response_format,
|
|
327
|
+
validate: processResponse
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Use promptTextRetry because we expect a string response to parse as JSON
|
|
331
|
+
return llmRetryClient.promptTextRetry(retryOptions);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function isPromptZodCached<T extends ZodTypeAny>(
|
|
335
|
+
schema: T,
|
|
336
|
+
options?: ZodLlmClientOptions
|
|
337
|
+
): Promise<boolean>;
|
|
338
|
+
async function isPromptZodCached<T extends ZodTypeAny>(
|
|
339
|
+
prompt: string,
|
|
340
|
+
schema: T,
|
|
341
|
+
options?: ZodLlmClientOptions
|
|
342
|
+
): Promise<boolean>;
|
|
343
|
+
async function isPromptZodCached<T extends ZodTypeAny>(
|
|
344
|
+
mainInstruction: string,
|
|
345
|
+
userMessagePayload: string | OpenAI.Chat.Completions.ChatCompletionContentPart[],
|
|
346
|
+
dataExtractionSchema: T,
|
|
347
|
+
options?: ZodLlmClientOptions
|
|
348
|
+
): Promise<boolean>;
|
|
349
|
+
async function isPromptZodCached<T extends ZodTypeAny>(
|
|
350
|
+
arg1: string | T,
|
|
351
|
+
arg2?: string | OpenAI.Chat.Completions.ChatCompletionContentPart[] | T | ZodLlmClientOptions,
|
|
352
|
+
arg3?: T | ZodLlmClientOptions,
|
|
353
|
+
arg4?: ZodLlmClientOptions
|
|
354
|
+
): Promise<boolean> {
|
|
355
|
+
const { mainInstruction, userMessagePayload, dataExtractionSchema, options } = normalizeZodArgs(arg1, arg2, arg3, arg4);
|
|
356
|
+
|
|
357
|
+
const { finalMainInstruction, response_format } = _getZodPromptConfig(
|
|
358
|
+
mainInstruction,
|
|
359
|
+
dataExtractionSchema,
|
|
360
|
+
options
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
|
364
|
+
{ role: "system", content: finalMainInstruction },
|
|
365
|
+
{ role: "user", content: userMessagePayload }
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
const { maxRetries, useResponseFormat: _u, beforeValidation, ...restOptions } = options || {};
|
|
369
|
+
|
|
370
|
+
return isPromptCached({
|
|
371
|
+
messages,
|
|
372
|
+
response_format,
|
|
373
|
+
...restOptions
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return { promptZod, isPromptZodCached };
|
|
378
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createLlmClient, CreateLlmClientParams } from "./createLlmClient.js";
|
|
2
|
+
import { createLlmRetryClient } from "./createLlmRetryClient.js";
|
|
3
|
+
import { createZodLlmClient } from "./createZodLlmClient.js";
|
|
4
|
+
|
|
5
|
+
export interface CreateLlmFactoryParams extends CreateLlmClientParams {
|
|
6
|
+
// Optional overrides for specific sub-clients if needed, but usually just base params
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createLlm(params: CreateLlmFactoryParams) {
|
|
10
|
+
const baseClient = createLlmClient(params);
|
|
11
|
+
|
|
12
|
+
const retryClient = createLlmRetryClient({
|
|
13
|
+
prompt: baseClient.prompt
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const zodClient = createZodLlmClient({
|
|
17
|
+
prompt: baseClient.prompt,
|
|
18
|
+
isPromptCached: baseClient.isPromptCached
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
...baseClient,
|
|
23
|
+
...retryClient,
|
|
24
|
+
...zodClient
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// retryUtils.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Describes the outcome of a validation/processing step in a retry loop.
|
|
5
|
+
*/
|
|
6
|
+
export interface RetryValidationResult<ValidatedDataType, FeedbackType = any> {
|
|
7
|
+
isValid: boolean;
|
|
8
|
+
data?: ValidatedDataType; // The successfully validated data, if isValid is true.
|
|
9
|
+
feedbackForNextAttempt?: FeedbackType; // Information to guide the next attempt of the operation.
|
|
10
|
+
isCriticalFailure?: boolean; // If true, indicates an unrecoverable error, stopping retries immediately.
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Executes an operation with a retry mechanism.
|
|
15
|
+
*
|
|
16
|
+
* @param operation - An async function representing the operation to be tried.
|
|
17
|
+
* It receives the current attempt number and feedback from the previous validation.
|
|
18
|
+
* @param validateAndProcess - An async function that processes the raw result from the operation
|
|
19
|
+
* and validates it. It returns a RetryValidationResult.
|
|
20
|
+
* @param maxRetries - The maximum number of retries after the initial attempt (e.g., 2 means 3 total attempts).
|
|
21
|
+
* @param initialFeedbackForOperation - Optional initial feedback to pass to the very first call of the operation.
|
|
22
|
+
* @param shouldRetryError - Optional function to determine if a specific error should trigger a retry. Returns true to retry, false to throw immediately.
|
|
23
|
+
* @returns A Promise that resolves with the validated data if successful.
|
|
24
|
+
* @throws An error if all attempts fail or a critical failure occurs.
|
|
25
|
+
*/
|
|
26
|
+
export async function executeWithRetry<OperationReturnType, ValidatedDataType, FeedbackType = any>(
|
|
27
|
+
operation: (attemptNumber: number, feedbackForOperation?: FeedbackType) => Promise<OperationReturnType>,
|
|
28
|
+
validateAndProcess: (
|
|
29
|
+
rawResult: OperationReturnType,
|
|
30
|
+
attemptNumber: number
|
|
31
|
+
) => Promise<RetryValidationResult<ValidatedDataType, FeedbackType>>,
|
|
32
|
+
maxRetries: number,
|
|
33
|
+
initialFeedbackForOperation?: FeedbackType,
|
|
34
|
+
shouldRetryError?: (error: any) => boolean
|
|
35
|
+
): Promise<ValidatedDataType> {
|
|
36
|
+
let currentFeedbackForOperation: FeedbackType | undefined = initialFeedbackForOperation;
|
|
37
|
+
|
|
38
|
+
for (let attemptNumber = 0; attemptNumber <= maxRetries; attemptNumber++) {
|
|
39
|
+
if (attemptNumber > 0) {
|
|
40
|
+
// Exponential backoff with jitter.
|
|
41
|
+
const baseDelay = 1000; // 1 second
|
|
42
|
+
const backoffTime = baseDelay * Math.pow(2, attemptNumber - 1);
|
|
43
|
+
const jitter = backoffTime * (Math.random() * 0.2); // Add up to 20% jitter
|
|
44
|
+
const totalDelay = backoffTime + jitter;
|
|
45
|
+
console.log(`Retrying operation... Attempt ${attemptNumber + 1} of ${maxRetries + 1}. Waiting for ${Math.round(totalDelay)}ms.`);
|
|
46
|
+
await new Promise(resolve => setTimeout(resolve, totalDelay));
|
|
47
|
+
}
|
|
48
|
+
let rawResult: OperationReturnType;
|
|
49
|
+
try {
|
|
50
|
+
rawResult = await operation(attemptNumber, currentFeedbackForOperation);
|
|
51
|
+
} catch (opError: any) {
|
|
52
|
+
// Check if we should stop retrying based on the error type
|
|
53
|
+
if (shouldRetryError && !shouldRetryError(opError)) {
|
|
54
|
+
throw opError;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Error directly from the operation (e.g., network failure, `this.ask` throws)
|
|
58
|
+
if (attemptNumber >= maxRetries) {
|
|
59
|
+
// On the final attempt, throw an error that preserves the entire causal chain.
|
|
60
|
+
throw new Error(`Operation failed on final attempt ${attemptNumber + 1}. See cause for details.`, { cause: opError });
|
|
61
|
+
}
|
|
62
|
+
// Provide feedback about the operation's own exception for the next attempt
|
|
63
|
+
currentFeedbackForOperation = {
|
|
64
|
+
type: 'OPERATION_EXCEPTION', // You'll need to define this in your FeedbackType
|
|
65
|
+
message: opError.message,
|
|
66
|
+
rawResponseSnippet: "N/A - Operation threw before producing a response.",
|
|
67
|
+
// Include the cause's stack if available, otherwise the operation error's stack
|
|
68
|
+
details: opError.cause?.stack || opError.stack
|
|
69
|
+
} as unknown as FeedbackType; // Cast as FeedbackType, ensure your type supports this structure
|
|
70
|
+
continue; // Go to the next attempt
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const validationOutcome = await validateAndProcess(rawResult, attemptNumber);
|
|
74
|
+
|
|
75
|
+
if (validationOutcome.isValid && validationOutcome.data !== undefined) {
|
|
76
|
+
return validationOutcome.data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
currentFeedbackForOperation = validationOutcome.feedbackForNextAttempt;
|
|
80
|
+
|
|
81
|
+
if (validationOutcome.isCriticalFailure) {
|
|
82
|
+
throw new Error(`Critical failure encountered on attempt ${attemptNumber + 1}. Reason: ${JSON.stringify(currentFeedbackForOperation) || 'Unknown critical failure'}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (attemptNumber >= maxRetries) {
|
|
86
|
+
throw new Error(`All ${maxRetries + 1} attempts failed. Last failure reason: ${JSON.stringify(currentFeedbackForOperation)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// This line should be theoretically unreachable if maxRetries >= 0
|
|
90
|
+
throw new Error("Exited retry loop unexpectedly. This indicates an issue with the retry logic itself.");
|
|
91
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createTestLlm } from './setup.js';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
|
|
5
|
+
describe('Basic LLM Integration', () => {
|
|
6
|
+
it('should generate text from a simple prompt (Power User Interface)', async () => {
|
|
7
|
+
const { llm } = await createTestLlm();
|
|
8
|
+
|
|
9
|
+
const response = await llm.promptText({
|
|
10
|
+
messages: [{ role: 'user', content: 'Say "Hello Integration Test"' }],
|
|
11
|
+
temperature: 0,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(response).toBeTruthy();
|
|
15
|
+
expect(response).toContain('Hello Integration Test');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should cache responses', async () => {
|
|
19
|
+
const { llm } = await createTestLlm();
|
|
20
|
+
const uniqueId = crypto.randomUUID();
|
|
21
|
+
const promptOptions = {
|
|
22
|
+
messages: [{ role: 'user', content: `Repeat this ID: ${uniqueId}` }],
|
|
23
|
+
temperature: 0,
|
|
24
|
+
ttl: 5000, // 5 seconds
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// First call - should hit API
|
|
28
|
+
const start1 = Date.now();
|
|
29
|
+
const response1 = await llm.promptText(promptOptions);
|
|
30
|
+
|
|
31
|
+
expect(response1).toContain(uniqueId);
|
|
32
|
+
|
|
33
|
+
// Check if cached
|
|
34
|
+
const isCached = await llm.isPromptCached(promptOptions);
|
|
35
|
+
expect(isCached).toBe(true);
|
|
36
|
+
|
|
37
|
+
// Second call - should be fast (cached)
|
|
38
|
+
const start2 = Date.now();
|
|
39
|
+
const response2 = await llm.promptText(promptOptions);
|
|
40
|
+
const duration2 = Date.now() - start2;
|
|
41
|
+
|
|
42
|
+
expect(response2).toBe(response1);
|
|
43
|
+
// API calls usually take > 200ms. Cache hits are usually < 10ms.
|
|
44
|
+
// We'll be generous and say second call should be significantly faster or under 100ms.
|
|
45
|
+
expect(duration2).toBeLessThan(100);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/tests/env.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
|
|
4
|
+
// Load .env.test explicitly if available, falling back to .env
|
|
5
|
+
dotenv.config({ path: '.env.test' });
|
|
6
|
+
dotenv.config();
|
|
7
|
+
|
|
8
|
+
const envSchema = z.object({
|
|
9
|
+
OPENAI_API_KEY: z.string().min(1, "OPENAI_API_KEY is required"),
|
|
10
|
+
// Optional override for base URL (e.g. for OpenRouter or local proxies)
|
|
11
|
+
OPENAI_BASE_URL: z.string().url().optional(),
|
|
12
|
+
// Model to use for testing. Defaults to a cheaper model.
|
|
13
|
+
TEST_MODEL: z.string().default("openai/gpt-oss-120b"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const env = envSchema.parse(process.env);
|
package/tests/setup.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { createCache } from 'cache-manager';
|
|
3
|
+
import KeyvSqlite from '@keyv/sqlite';
|
|
4
|
+
import { createLlm } from '../src/llmFactory.js';
|
|
5
|
+
import { env } from './env.js';
|
|
6
|
+
|
|
7
|
+
export async function createTestLlm() {
|
|
8
|
+
const openai = new OpenAI({
|
|
9
|
+
apiKey: env.OPENAI_API_KEY,
|
|
10
|
+
baseURL: env.OPENAI_BASE_URL,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Create a SQLite cache for testing
|
|
14
|
+
const sqliteStore = new KeyvSqlite('sqlite://test-cache.sqlite');
|
|
15
|
+
const cache = createCache({ stores: [sqliteStore as any] });
|
|
16
|
+
|
|
17
|
+
const llm = createLlm({
|
|
18
|
+
openai,
|
|
19
|
+
cache,
|
|
20
|
+
defaultModel: env.TEST_MODEL,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return { llm, cache };
|
|
24
|
+
}
|