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