@sprqvntrs/llm 3.13.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 +21 -0
- package/README.md +595 -0
- package/index.ts +53 -0
- package/package.json +56 -0
- package/src/clients/anthropic-client.ts +636 -0
- package/src/clients/openai-client.ts +548 -0
- package/src/clients/openrouter-client.ts +802 -0
- package/src/helpers.ts +185 -0
- package/src/llm.ts +107 -0
- package/src/model-types.ts +61 -0
- package/src/models.ts +98 -0
- package/src/pricing-data.json +1651 -0
- package/src/pricing.ts +36 -0
- package/src/types/client-interface.ts +222 -0
- package/src/utils/debug.ts +120 -0
- package/src/utils/errors.ts +508 -0
- package/src/utils/normalize-null-strings.ts +103 -0
- package/src/utils/resolve-refs.ts +91 -0
- package/src/utils/strip-json-artifacts.ts +98 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { OpenAI } from 'openai';
|
|
2
|
+
import { zodTextFormat } from 'openai/helpers/zod';
|
|
3
|
+
import { z } from 'zod/v4';
|
|
4
|
+
import type {
|
|
5
|
+
LlmClientInterface,
|
|
6
|
+
BaseLlmClientConfig,
|
|
7
|
+
StreamChunk,
|
|
8
|
+
LlmTokenUsage,
|
|
9
|
+
ReasoningEffortLevel,
|
|
10
|
+
} from '../types/client-interface';
|
|
11
|
+
import { DEFAULT_MODELS, WEB_SEARCH_TOOLS, DEFAULT_SYSTEM_PROMPT } from '../models';
|
|
12
|
+
import { calculateUsageCost } from '../pricing';
|
|
13
|
+
import { DebugLogger } from '../utils/debug';
|
|
14
|
+
import {
|
|
15
|
+
generateRequestId,
|
|
16
|
+
wrapSdkError,
|
|
17
|
+
isRetryableError,
|
|
18
|
+
type LlmErrorContext,
|
|
19
|
+
} from '../utils/errors';
|
|
20
|
+
|
|
21
|
+
export interface OpenAIClientConfig extends Omit<BaseLlmClientConfig, 'model'> {
|
|
22
|
+
/**
|
|
23
|
+
* The model to use (default: DEFAULT_MODELS.OPENAI_DEFAULT.model)
|
|
24
|
+
*/
|
|
25
|
+
model?: OpenAI.AllModels;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Client for interacting with OpenAI's API with enhanced functionality
|
|
30
|
+
* for structured responses, batch processing, and embeddings.
|
|
31
|
+
* Implements the unified LlmClientInterface.
|
|
32
|
+
*/
|
|
33
|
+
export class OpenAIClient implements LlmClientInterface {
|
|
34
|
+
private openai: OpenAI;
|
|
35
|
+
private model: string;
|
|
36
|
+
private logger: DebugLogger;
|
|
37
|
+
private timeout: number;
|
|
38
|
+
private maxRetries: number;
|
|
39
|
+
private _lastUsage: LlmTokenUsage | null = null;
|
|
40
|
+
|
|
41
|
+
get lastUsage(): LlmTokenUsage | null {
|
|
42
|
+
return this._lastUsage;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a new OpenAIClient instance
|
|
47
|
+
*
|
|
48
|
+
* @param config Configuration options
|
|
49
|
+
* @throws Error if the API key is not configured
|
|
50
|
+
*/
|
|
51
|
+
constructor(config: OpenAIClientConfig) {
|
|
52
|
+
// Use config timeout or default to 120 seconds (2 minutes)
|
|
53
|
+
this.timeout = config.timeout ?? 120000;
|
|
54
|
+
this.maxRetries = config.maxRetries ?? 2;
|
|
55
|
+
|
|
56
|
+
this.openai = new OpenAI({
|
|
57
|
+
apiKey: config.apiKey,
|
|
58
|
+
timeout: this.timeout,
|
|
59
|
+
maxRetries: this.maxRetries,
|
|
60
|
+
});
|
|
61
|
+
this.model = config.model || DEFAULT_MODELS.OPENAI_DEFAULT.model;
|
|
62
|
+
this.logger = new DebugLogger('OpenAIClient', { enabled: config.debug });
|
|
63
|
+
|
|
64
|
+
// Validate configuration on instantiation
|
|
65
|
+
this.validateConfiguration();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validates that the client is properly configured
|
|
70
|
+
* @returns true if valid, throws an error with details if not
|
|
71
|
+
*/
|
|
72
|
+
validateConfiguration(): boolean {
|
|
73
|
+
if (!this.openai.apiKey) {
|
|
74
|
+
throw new Error('OpenAI API key is not configured');
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates a response from OpenAI's API and returns the text content
|
|
81
|
+
* Uses the responses API which is stateless
|
|
82
|
+
*
|
|
83
|
+
* @param prompt The prompt to send to the model
|
|
84
|
+
* @param options Optional configuration
|
|
85
|
+
* @param options.timeout Request timeout in milliseconds (overrides client default)
|
|
86
|
+
* @returns The text content as a string
|
|
87
|
+
*/
|
|
88
|
+
async createResponse(prompt: string, options?: { timeout?: number }): Promise<string> {
|
|
89
|
+
const response = await this.createRawResponse(prompt, options);
|
|
90
|
+
return this.extractContentFromResponse(response);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates a raw response from OpenAI's API and returns the full response object
|
|
95
|
+
* Use this when you need access to metadata like usage stats, finish reason, etc.
|
|
96
|
+
*
|
|
97
|
+
* @param prompt The prompt to send to the model
|
|
98
|
+
* @param options Optional configuration
|
|
99
|
+
* @param options.timeout Request timeout in milliseconds (overrides client default)
|
|
100
|
+
* @returns The raw response from OpenAI
|
|
101
|
+
*/
|
|
102
|
+
async createRawResponse(prompt: string, options?: { timeout?: number }): Promise<unknown> {
|
|
103
|
+
const effectiveTimeout = options?.timeout ?? this.timeout;
|
|
104
|
+
|
|
105
|
+
// Create a client with the effective timeout if different from instance timeout
|
|
106
|
+
const client = effectiveTimeout !== this.timeout
|
|
107
|
+
? new OpenAI({
|
|
108
|
+
apiKey: this.openai.apiKey,
|
|
109
|
+
timeout: effectiveTimeout,
|
|
110
|
+
maxRetries: this.maxRetries,
|
|
111
|
+
})
|
|
112
|
+
: this.openai;
|
|
113
|
+
|
|
114
|
+
const response = await client.responses.create({
|
|
115
|
+
input: prompt,
|
|
116
|
+
model: this.model,
|
|
117
|
+
});
|
|
118
|
+
return response;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Extracts text content from a raw OpenAI response object
|
|
123
|
+
* OpenAI's responses API returns responses with an 'output_text' field
|
|
124
|
+
*
|
|
125
|
+
* @param response The raw response from OpenAI
|
|
126
|
+
* @returns The text content as a string
|
|
127
|
+
* @throws Error if there is no content in the response
|
|
128
|
+
*/
|
|
129
|
+
private extractContentFromResponse(response: unknown): string {
|
|
130
|
+
const typedResponse = response as OpenAI.Responses.Response;
|
|
131
|
+
const text = typedResponse.output_text;
|
|
132
|
+
if (!text) {
|
|
133
|
+
throw new Error('No response content');
|
|
134
|
+
}
|
|
135
|
+
return text;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Creates a response with a prediction to guide the model's response
|
|
140
|
+
*
|
|
141
|
+
* Note: The responses API does not support predictions. This method will log a warning
|
|
142
|
+
* and fall back to the standard createResponse method.
|
|
143
|
+
*
|
|
144
|
+
* @param prompt The prompt to send to the model
|
|
145
|
+
* @param prediction The prediction content to guide the model's response (not used with responses API)
|
|
146
|
+
* @returns The text content as a string
|
|
147
|
+
* @deprecated Predictions are not supported by the responses API. Use createResponse instead.
|
|
148
|
+
*/
|
|
149
|
+
async createResponseWithPrediction(
|
|
150
|
+
prompt: string,
|
|
151
|
+
prediction: OpenAI.Chat.ChatCompletionPredictionContent,
|
|
152
|
+
): Promise<string> {
|
|
153
|
+
this.logger.log('Warning: Predictions are not supported by the responses API. Ignoring prediction parameter.', {
|
|
154
|
+
predictionProvided: !!prediction,
|
|
155
|
+
});
|
|
156
|
+
return this.createResponse(prompt);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates a streaming response from OpenAI's API
|
|
161
|
+
* Returns an async iterator that yields chunks of text as they arrive
|
|
162
|
+
*
|
|
163
|
+
* @param prompt The prompt to send to the model
|
|
164
|
+
* @param options Optional configuration
|
|
165
|
+
* @param options.timeout Request timeout in milliseconds (overrides client default)
|
|
166
|
+
* @returns An async iterable of stream chunks
|
|
167
|
+
*/
|
|
168
|
+
async *createStreamingResponse(prompt: string, options?: { timeout?: number }): AsyncIterable<StreamChunk> {
|
|
169
|
+
const requestId = generateRequestId();
|
|
170
|
+
const startTime = Date.now();
|
|
171
|
+
const effectiveTimeout = options?.timeout ?? this.timeout;
|
|
172
|
+
|
|
173
|
+
this.logger.log('createStreamingResponse called', {
|
|
174
|
+
modelUsed: this.model,
|
|
175
|
+
requestId,
|
|
176
|
+
timeout: effectiveTimeout,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
// Create a client with the effective timeout if different from instance timeout
|
|
181
|
+
const client = effectiveTimeout !== this.timeout
|
|
182
|
+
? new OpenAI({
|
|
183
|
+
apiKey: this.openai.apiKey,
|
|
184
|
+
timeout: effectiveTimeout,
|
|
185
|
+
maxRetries: this.maxRetries,
|
|
186
|
+
})
|
|
187
|
+
: this.openai;
|
|
188
|
+
|
|
189
|
+
const stream = client.responses.stream({
|
|
190
|
+
model: this.model,
|
|
191
|
+
input: prompt,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
let accumulatedText = '';
|
|
195
|
+
let usage: StreamChunk['usage'] | undefined;
|
|
196
|
+
|
|
197
|
+
for await (const chunk of stream) {
|
|
198
|
+
// Handle text delta events
|
|
199
|
+
if (chunk.type === 'response.output_text.delta' && 'delta' in chunk) {
|
|
200
|
+
const text = (chunk as any).delta;
|
|
201
|
+
if (text) {
|
|
202
|
+
accumulatedText += text;
|
|
203
|
+
|
|
204
|
+
yield {
|
|
205
|
+
text,
|
|
206
|
+
isComplete: false,
|
|
207
|
+
accumulatedText,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Capture usage from response.completed event
|
|
213
|
+
if (chunk.type === 'response.completed' && 'response' in chunk) {
|
|
214
|
+
const response = (chunk as any).response;
|
|
215
|
+
if (response?.usage) {
|
|
216
|
+
usage = {
|
|
217
|
+
promptTokens: response.usage.input_tokens,
|
|
218
|
+
completionTokens: response.usage.output_tokens,
|
|
219
|
+
totalTokens: response.usage.input_tokens + response.usage.output_tokens,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Final chunk with usage
|
|
226
|
+
const elapsedMs = Date.now() - startTime;
|
|
227
|
+
this.logger.log(`Streaming completed in ${elapsedMs}ms`);
|
|
228
|
+
if (usage) {
|
|
229
|
+
this.logger.logUsage(usage);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
yield {
|
|
233
|
+
text: '',
|
|
234
|
+
isComplete: true,
|
|
235
|
+
accumulatedText,
|
|
236
|
+
usage,
|
|
237
|
+
};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const elapsedMs = Date.now() - startTime;
|
|
240
|
+
const context: LlmErrorContext = {
|
|
241
|
+
clientType: 'openai',
|
|
242
|
+
model: this.model,
|
|
243
|
+
elapsedMs,
|
|
244
|
+
timeoutMs: effectiveTimeout,
|
|
245
|
+
operation: 'createStreamingResponse',
|
|
246
|
+
requestId,
|
|
247
|
+
metadata: {
|
|
248
|
+
promptSize: prompt.length,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const wrappedError = wrapSdkError(error, context);
|
|
253
|
+
this.logger.logError('Streaming failed', wrappedError, wrappedError.context);
|
|
254
|
+
throw wrappedError;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Creates a structured response using OpenAI's structured outputs API with Zod schema validation
|
|
260
|
+
* Includes built-in retry logic, execution time tracking, and error handling
|
|
261
|
+
*
|
|
262
|
+
* @param options Configuration options for the structured response
|
|
263
|
+
* @param options.prompt The prompt to send to the model
|
|
264
|
+
* @param options.schema The Zod schema to validate the response against
|
|
265
|
+
* @param options.formatGuidance Optional guidance for formatting the response
|
|
266
|
+
* @param options.reasoningEffort Normalized reasoning effort level ('none' | 'low' | 'medium' | 'high').
|
|
267
|
+
* Sent as `reasoning.effort` only when provided; pass 'none' to turn reasoning off.
|
|
268
|
+
* @param options.maxAttempts Maximum number of retry attempts (default: 1, no retries)
|
|
269
|
+
* @param options.logExecutionTime Whether to log execution time warnings (default: false)
|
|
270
|
+
* @param options.responseInstructions Additional instructions to append to the prompt (deprecated, use formatGuidance)
|
|
271
|
+
* @param options.useWebSearch Whether to enable web search for this request (default: false)
|
|
272
|
+
* @param options.stream Whether to use streaming for the generation phase (default: true)
|
|
273
|
+
* @param options.timeout Request timeout in milliseconds (overrides client default)
|
|
274
|
+
* @returns The structured and validated response according to the provided schema
|
|
275
|
+
* @throws Error if the response cannot be parsed or if the model refuses to respond
|
|
276
|
+
*/
|
|
277
|
+
async createStructuredResponse<T extends z.ZodType>({
|
|
278
|
+
prompt,
|
|
279
|
+
schema,
|
|
280
|
+
formatGuidance,
|
|
281
|
+
reasoningEffort,
|
|
282
|
+
maxAttempts = 3,
|
|
283
|
+
logExecutionTime = false,
|
|
284
|
+
responseInstructions,
|
|
285
|
+
useWebSearch = false,
|
|
286
|
+
stream = true,
|
|
287
|
+
timeout,
|
|
288
|
+
}: {
|
|
289
|
+
prompt: string;
|
|
290
|
+
schema: T;
|
|
291
|
+
formatGuidance?: string;
|
|
292
|
+
reasoningEffort?: ReasoningEffortLevel;
|
|
293
|
+
maxAttempts?: number;
|
|
294
|
+
logExecutionTime?: boolean;
|
|
295
|
+
responseInstructions?: string;
|
|
296
|
+
useWebSearch?: boolean;
|
|
297
|
+
stream?: boolean;
|
|
298
|
+
timeout?: number;
|
|
299
|
+
}): Promise<z.infer<T>> {
|
|
300
|
+
// Handle deprecated responseInstructions parameter
|
|
301
|
+
const effectiveFormatGuidance =
|
|
302
|
+
responseInstructions ?
|
|
303
|
+
(formatGuidance ? `${formatGuidance}\n\n${responseInstructions}` : responseInstructions)
|
|
304
|
+
: formatGuidance;
|
|
305
|
+
|
|
306
|
+
this._lastUsage = null;
|
|
307
|
+
|
|
308
|
+
const effectiveTimeout = timeout ?? this.timeout;
|
|
309
|
+
const requestId = generateRequestId();
|
|
310
|
+
let attempts = 0;
|
|
311
|
+
let lastError: unknown;
|
|
312
|
+
|
|
313
|
+
// Create a client with the effective timeout if different from instance timeout
|
|
314
|
+
const client = effectiveTimeout !== this.timeout
|
|
315
|
+
? new OpenAI({
|
|
316
|
+
apiKey: this.openai.apiKey,
|
|
317
|
+
timeout: effectiveTimeout,
|
|
318
|
+
maxRetries: this.maxRetries,
|
|
319
|
+
})
|
|
320
|
+
: this.openai;
|
|
321
|
+
|
|
322
|
+
while (attempts < maxAttempts) {
|
|
323
|
+
attempts++;
|
|
324
|
+
const startTime = Date.now();
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
this.logger.log('createStructuredResponse called', {
|
|
328
|
+
modelUsed: this.model,
|
|
329
|
+
schemaType: typeof schema,
|
|
330
|
+
schemaConstructor: schema?.constructor?.name,
|
|
331
|
+
reasoningEffort,
|
|
332
|
+
attempt: `${attempts}/${maxAttempts}`,
|
|
333
|
+
stream,
|
|
334
|
+
requestId,
|
|
335
|
+
timeout: effectiveTimeout,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Create text format using zodTextFormat
|
|
339
|
+
const textFormat = zodTextFormat(schema, 'structuredResponse');
|
|
340
|
+
|
|
341
|
+
// Build tools array if web search is enabled
|
|
342
|
+
const tools = useWebSearch ? [WEB_SEARCH_TOOLS.OPENAI] : undefined;
|
|
343
|
+
|
|
344
|
+
let parsedOutput: any;
|
|
345
|
+
|
|
346
|
+
if (stream) {
|
|
347
|
+
// Use streaming for better observability - accumulate the response then parse
|
|
348
|
+
this.logger.log('Using streaming generation for observability');
|
|
349
|
+
let accumulatedText = '';
|
|
350
|
+
let lastLoggedLength = 0;
|
|
351
|
+
|
|
352
|
+
// Stream the response with structured output format
|
|
353
|
+
const streamResponse = client.responses.stream({
|
|
354
|
+
model: this.model,
|
|
355
|
+
...(reasoningEffort && { reasoning: { effort: reasoningEffort } }),
|
|
356
|
+
instructions:
|
|
357
|
+
DEFAULT_SYSTEM_PROMPT +
|
|
358
|
+
'\n\nRespond with valid data matching the provided schema.' +
|
|
359
|
+
(effectiveFormatGuidance ? `\n\n${effectiveFormatGuidance}` : ''),
|
|
360
|
+
input: prompt,
|
|
361
|
+
text: {
|
|
362
|
+
format: textFormat,
|
|
363
|
+
},
|
|
364
|
+
...(tools && { tools }),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
for await (const chunk of streamResponse) {
|
|
368
|
+
if (chunk.type === 'response.output_text.delta' && 'delta' in chunk) {
|
|
369
|
+
const text = (chunk as any).delta;
|
|
370
|
+
if (text) {
|
|
371
|
+
accumulatedText += text;
|
|
372
|
+
|
|
373
|
+
// Log progress every 100 characters for observability
|
|
374
|
+
if (accumulatedText.length - lastLoggedLength >= 100) {
|
|
375
|
+
this.logger.log(`Streaming progress: ${accumulatedText.length} chars`);
|
|
376
|
+
lastLoggedLength = accumulatedText.length;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Capture usage from response.completed event
|
|
382
|
+
if (chunk.type === 'response.completed' && 'response' in chunk) {
|
|
383
|
+
const response = (chunk as any).response;
|
|
384
|
+
if (response?.usage) {
|
|
385
|
+
const promptTokens = response.usage.input_tokens ?? 0;
|
|
386
|
+
const completionTokens = response.usage.output_tokens ?? 0;
|
|
387
|
+
const cachedTokens: number | undefined = response.usage.input_tokens_details?.cached_tokens ?? undefined;
|
|
388
|
+
this._lastUsage = {
|
|
389
|
+
promptTokens,
|
|
390
|
+
completionTokens,
|
|
391
|
+
totalTokens: promptTokens + completionTokens,
|
|
392
|
+
cachedTokens,
|
|
393
|
+
model: this.model,
|
|
394
|
+
cost: calculateUsageCost(this.model, promptTokens, completionTokens, cachedTokens),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
this.logger.log(`Streaming completed, total: ${accumulatedText.length} chars, parsing...`);
|
|
401
|
+
|
|
402
|
+
// Parse the accumulated JSON response
|
|
403
|
+
try {
|
|
404
|
+
parsedOutput = JSON.parse(accumulatedText);
|
|
405
|
+
} catch (parseError) {
|
|
406
|
+
throw new Error(`Failed to parse streamed response as JSON: ${parseError}`);
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
// Non-streaming path using responses.parse API
|
|
410
|
+
const response = await client.responses.parse({
|
|
411
|
+
model: this.model,
|
|
412
|
+
...(reasoningEffort && { reasoning: { effort: reasoningEffort } }),
|
|
413
|
+
instructions:
|
|
414
|
+
DEFAULT_SYSTEM_PROMPT +
|
|
415
|
+
'\n\nRespond with valid data matching the provided schema.' +
|
|
416
|
+
(effectiveFormatGuidance ? `\n\n${effectiveFormatGuidance}` : ''),
|
|
417
|
+
input: prompt,
|
|
418
|
+
text: {
|
|
419
|
+
format: textFormat,
|
|
420
|
+
},
|
|
421
|
+
...(tools && { tools }),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (response.usage) {
|
|
425
|
+
const promptTokens = response.usage.input_tokens ?? 0;
|
|
426
|
+
const completionTokens = response.usage.output_tokens ?? 0;
|
|
427
|
+
const cachedTokens: number | undefined = (response.usage as any).input_tokens_details?.cached_tokens ?? undefined;
|
|
428
|
+
this._lastUsage = {
|
|
429
|
+
promptTokens,
|
|
430
|
+
completionTokens,
|
|
431
|
+
totalTokens: promptTokens + completionTokens,
|
|
432
|
+
cachedTokens,
|
|
433
|
+
model: this.model,
|
|
434
|
+
cost: calculateUsageCost(this.model, promptTokens, completionTokens, cachedTokens),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
this.logger.logUsage(response.usage || {});
|
|
438
|
+
parsedOutput = response.output_parsed;
|
|
439
|
+
|
|
440
|
+
if (!parsedOutput) {
|
|
441
|
+
throw new Error('No parsed output in response');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Validate with the schema (for extra safety)
|
|
446
|
+
const validatedContent = schema.parse(parsedOutput);
|
|
447
|
+
|
|
448
|
+
// Log execution time
|
|
449
|
+
const executionTime = Date.now() - startTime;
|
|
450
|
+
if (logExecutionTime || this.logger.isEnabled()) {
|
|
451
|
+
this.logger.logExecutionTime('createStructuredResponse', executionTime);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return validatedContent;
|
|
455
|
+
} catch (error) {
|
|
456
|
+
lastError = error;
|
|
457
|
+
const elapsedMs = Date.now() - startTime;
|
|
458
|
+
|
|
459
|
+
const context: LlmErrorContext = {
|
|
460
|
+
clientType: 'openai',
|
|
461
|
+
model: this.model,
|
|
462
|
+
elapsedMs,
|
|
463
|
+
timeoutMs: effectiveTimeout,
|
|
464
|
+
operation: 'createStructuredResponse',
|
|
465
|
+
requestId,
|
|
466
|
+
metadata: {
|
|
467
|
+
promptSize: prompt.length,
|
|
468
|
+
schemaComplexity: typeof schema,
|
|
469
|
+
attempt: attempts,
|
|
470
|
+
maxAttempts,
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const wrappedError = wrapSdkError(error, context);
|
|
475
|
+
const retryable = isRetryableError(wrappedError);
|
|
476
|
+
|
|
477
|
+
this.logger.log(`Failed attempt ${attempts}/${maxAttempts}`, {
|
|
478
|
+
error: wrappedError.message,
|
|
479
|
+
errorType: wrappedError.name,
|
|
480
|
+
retryable,
|
|
481
|
+
elapsedMs,
|
|
482
|
+
...(wrappedError.context.metadata?.errorCode && { errorCode: wrappedError.context.metadata.errorCode }),
|
|
483
|
+
...(wrappedError.context.metadata?.providerRequestId && { providerRequestId: wrappedError.context.metadata.providerRequestId }),
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
if (!retryable || attempts === maxAttempts) {
|
|
487
|
+
this.logger.logError(
|
|
488
|
+
`createStructuredResponse failed${retryable ? ' after all attempts' : ' (non-retryable)'}`,
|
|
489
|
+
wrappedError,
|
|
490
|
+
wrappedError.context,
|
|
491
|
+
);
|
|
492
|
+
throw wrappedError;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Wait before retrying (exponential backoff)
|
|
496
|
+
const backoffMs = Math.min(1000 * Math.pow(2, attempts - 1), 10000);
|
|
497
|
+
this.logger.log(`Retrying after ${backoffMs}ms backoff (attempt ${attempts + 1}/${maxAttempts})...`);
|
|
498
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
throw lastError || new Error('Failed to get structured response from LLM');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Process a batch of items with an LLM using parallel processing
|
|
507
|
+
*/
|
|
508
|
+
async processBatchWithLLM<T, R>({
|
|
509
|
+
items,
|
|
510
|
+
processFn,
|
|
511
|
+
batchSize = 5,
|
|
512
|
+
}: {
|
|
513
|
+
items: T[];
|
|
514
|
+
processFn: (batch: T[]) => Promise<R[]>;
|
|
515
|
+
batchSize?: number;
|
|
516
|
+
}): Promise<R[]> {
|
|
517
|
+
// Split items into batches
|
|
518
|
+
const batches: T[][] = [];
|
|
519
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
520
|
+
batches.push(items.slice(i, i + batchSize));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Process all batches in parallel
|
|
524
|
+
const results = await Promise.all(batches.map(processFn));
|
|
525
|
+
|
|
526
|
+
// Flatten the results array
|
|
527
|
+
return results.flat();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Generates an embedding vector for the provided text
|
|
532
|
+
*
|
|
533
|
+
* @param value The text to generate an embedding for
|
|
534
|
+
* @returns An array of numbers representing the embedding vector
|
|
535
|
+
*/
|
|
536
|
+
async generateEmbedding(value: string): Promise<number[]> {
|
|
537
|
+
const input = value.replaceAll('\n', ' ');
|
|
538
|
+
const { data } = await this.openai.embeddings.create({
|
|
539
|
+
model: 'text-embedding-ada-002',
|
|
540
|
+
input,
|
|
541
|
+
});
|
|
542
|
+
const embedding = data[0]?.embedding;
|
|
543
|
+
if (!embedding) {
|
|
544
|
+
throw new Error('No embedding returned from OpenAI');
|
|
545
|
+
}
|
|
546
|
+
return embedding;
|
|
547
|
+
}
|
|
548
|
+
}
|