@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/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@sprqvntrs/llm",
3
+ "version": "3.13.0",
4
+ "type": "module",
5
+ "main": "./index.ts",
6
+ "types": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/SPRQVNTRS/platform.git",
14
+ "directory": "packages/llm"
15
+ },
16
+ "files": [
17
+ "src/**/*",
18
+ "index.ts",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "test:unit": "vitest run",
23
+ "test": "vitest run",
24
+ "test:anthropic": "tsx tests/anthropic-client.test.ts",
25
+ "test:openrouter": "tsx tests/openrouter-client.test.ts",
26
+ "test:openai": "tsx tests/openai-client.test.ts",
27
+ "test:truncation": "tsx tests/output-truncated-error.test.ts",
28
+ "sync-pricing": "tsx scripts/sync-pricing.ts",
29
+ "test:live": "tsx tests/anthropic-client.test.ts && tsx tests/openrouter-client.test.ts && tsx tests/openai-client.test.ts"
30
+ },
31
+ "dependencies": {
32
+ "@anthropic-ai/sdk": "^0.91.1",
33
+ "@openrouter/sdk": "^0.12.22",
34
+ "openai": "^6.35.0",
35
+ "qs": "^6.12.1"
36
+ },
37
+ "peerDependencies": {
38
+ "zod": "^4.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/flat-cache": "^2.0.2",
42
+ "@types/node": "^22.0.0",
43
+ "@types/qs": "^6.9.15",
44
+ "dotenv": "^17.2.3",
45
+ "tsx": "^4.20.6",
46
+ "typescript": "^5.6.0",
47
+ "vitest": "^3.2.4"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "homepage": "https://github.com/SPRQVNTRS/platform/tree/main/packages/llm#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/SPRQVNTRS/platform/issues"
55
+ }
56
+ }
@@ -0,0 +1,636 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { z } from 'zod/v4';
3
+ import type {
4
+ LlmClientInterface,
5
+ BaseLlmClientConfig,
6
+ StreamChunk,
7
+ LlmTokenUsage,
8
+ ReasoningEffortLevel,
9
+ } from '../types/client-interface';
10
+ import { DEFAULT_MODELS, ANTHROPIC_MAX_TOKENS, WEB_SEARCH_TOOLS, DEFAULT_SYSTEM_PROMPT } from '../models';
11
+ import { calculateUsageCost } from '../pricing';
12
+ import { OpenAIClient } from './openai-client';
13
+ import type { AnthropicModel } from '../model-types';
14
+ import { DebugLogger } from '../utils/debug';
15
+ import {
16
+ generateRequestId,
17
+ wrapSdkError,
18
+ isRetryableError,
19
+ type LlmErrorContext,
20
+ } from '../utils/errors';
21
+
22
+ export interface AnthropicClientConfig extends Omit<BaseLlmClientConfig, 'model'> {
23
+ /**
24
+ * The Anthropic model to use
25
+ */
26
+ model: AnthropicModel;
27
+
28
+ /**
29
+ * Optional OpenAI API key for structured formatting (auto-detected from env if not provided)
30
+ */
31
+ openaiApiKey?: string;
32
+ }
33
+
34
+ /**
35
+ * Client for interacting with Anthropic's API.
36
+ * Implements the unified LlmClientInterface.
37
+ * Uses OpenAI for structured output formatting when needed.
38
+ */
39
+ export class AnthropicClient implements LlmClientInterface {
40
+ private client: Anthropic;
41
+ private model: AnthropicModel;
42
+ private formatterClient?: OpenAIClient;
43
+ private logger: DebugLogger;
44
+ private timeout: number;
45
+ private maxRetries: number;
46
+ private _lastUsage: LlmTokenUsage | null = null;
47
+
48
+ get lastUsage(): LlmTokenUsage | null {
49
+ return this._lastUsage;
50
+ }
51
+
52
+ /**
53
+ * Creates a new AnthropicClient instance
54
+ *
55
+ * @param config Configuration options
56
+ * @throws Error if the Anthropic API key is not configured
57
+ */
58
+ constructor(config: AnthropicClientConfig) {
59
+ // Use config timeout or default to 120 seconds (2 minutes)
60
+ this.timeout = config.timeout ?? 120000;
61
+ this.maxRetries = config.maxRetries ?? 2;
62
+
63
+ this.client = new Anthropic({
64
+ apiKey: config.apiKey,
65
+ timeout: this.timeout,
66
+ maxRetries: this.maxRetries,
67
+ });
68
+ this.model = config.model;
69
+ this.logger = new DebugLogger('AnthropicClient', { enabled: config.debug });
70
+
71
+ // Auto-detect OpenAI API key from environment if not explicitly provided
72
+ const openaiKey = config.openaiApiKey || process.env.OPENAI_API_KEY;
73
+
74
+ // Initialize OpenAI formatter if API key is available
75
+ if (openaiKey) {
76
+ this.formatterClient = new OpenAIClient({
77
+ apiKey: openaiKey,
78
+ model: DEFAULT_MODELS.STRUCTURED_FORMATTER.model,
79
+ debug: config.debug,
80
+ timeout: this.timeout,
81
+ maxRetries: this.maxRetries,
82
+ });
83
+ }
84
+
85
+ // Validate configuration on instantiation
86
+ this.validateConfiguration();
87
+ }
88
+
89
+ /**
90
+ * Set the OpenAI formatter client for structured output
91
+ *
92
+ * @param openaiApiKey OpenAI API key
93
+ */
94
+ setFormatterClient(openaiApiKey: string, debug?: boolean): void {
95
+ this.formatterClient = new OpenAIClient({
96
+ apiKey: openaiApiKey,
97
+ model: DEFAULT_MODELS.STRUCTURED_FORMATTER.model,
98
+ debug,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Validates that the client is properly configured
104
+ * @returns true if valid, throws an error with details if not
105
+ */
106
+ validateConfiguration(): boolean {
107
+ if (!this.client.apiKey) {
108
+ throw new Error('Anthropic API key is not configured');
109
+ }
110
+
111
+ // Warn if OpenAI formatter is not available (but don't throw - fallback exists)
112
+ if (!this.formatterClient) {
113
+ this.logger.log(
114
+ '⚠️ OpenAI API key not found. Anthropic will attempt direct JSON generation (less reliable). ' +
115
+ 'Set OPENAI_API_KEY environment variable for better structured output.',
116
+ );
117
+ }
118
+
119
+ return true;
120
+ }
121
+
122
+ /**
123
+ * Creates a response from Anthropic's API and returns the text content
124
+ *
125
+ * @param prompt The prompt to send to the model
126
+ * @param options Optional configuration
127
+ * @param options.timeout Request timeout in milliseconds (overrides client default)
128
+ * @returns The text content as a string
129
+ */
130
+ async createResponse(prompt: string, options?: { timeout?: number }): Promise<string> {
131
+ const response = await this.createRawResponse(prompt, options);
132
+ return this.extractContentFromResponse(response);
133
+ }
134
+
135
+ /**
136
+ * Creates a raw response from Anthropic's API and returns the full response object
137
+ * Use this when you need access to metadata like usage stats, finish reason, etc.
138
+ *
139
+ * @param prompt The prompt to send to the model
140
+ * @param options Optional configuration
141
+ * @param options.timeout Request timeout in milliseconds (overrides client default)
142
+ * @returns The raw message response from Anthropic
143
+ */
144
+ async createRawResponse(prompt: string, options?: { timeout?: number }): Promise<unknown> {
145
+ const effectiveTimeout = options?.timeout ?? this.timeout;
146
+
147
+ // Create a client with the effective timeout if different from instance timeout
148
+ const client = effectiveTimeout !== this.timeout
149
+ ? new Anthropic({
150
+ apiKey: this.client.apiKey,
151
+ timeout: effectiveTimeout,
152
+ maxRetries: this.maxRetries,
153
+ })
154
+ : this.client;
155
+
156
+ const response = await client.messages.create({
157
+ model: this.model,
158
+ max_tokens: ANTHROPIC_MAX_TOKENS,
159
+ system: DEFAULT_SYSTEM_PROMPT,
160
+ messages: [
161
+ {
162
+ role: 'user',
163
+ content: prompt,
164
+ },
165
+ ],
166
+ });
167
+ return response;
168
+ }
169
+
170
+ /**
171
+ * Extracts text content from a raw Anthropic response object
172
+ * Anthropic's messages API returns responses with content blocks
173
+ *
174
+ * @param response The raw response from Anthropic
175
+ * @returns The text content as a string
176
+ * @throws Error if there is no content in the response
177
+ */
178
+ private extractContentFromResponse(response: unknown): string {
179
+ const typedResponse = response as Anthropic.Message;
180
+ const textBlock = typedResponse.content.find((block) => block.type === 'text');
181
+ if (!textBlock || textBlock.type !== 'text') {
182
+ throw new Error('No text content in Anthropic response');
183
+ }
184
+ return textBlock.text;
185
+ }
186
+
187
+ /**
188
+ * Get the underlying Anthropic SDK client
189
+ * This allows access to all native Anthropic SDK methods
190
+ */
191
+ get sdk(): Anthropic {
192
+ return this.client;
193
+ }
194
+
195
+ /**
196
+ * Get the model being used
197
+ */
198
+ get currentModel(): string {
199
+ return this.model;
200
+ }
201
+
202
+ /**
203
+ * Creates a streaming response from Anthropic's API
204
+ * Returns an async iterator that yields chunks of text as they arrive
205
+ *
206
+ * @param prompt The prompt to send to the model
207
+ * @param options Optional configuration
208
+ * @param options.timeout Request timeout in milliseconds (overrides client default)
209
+ * @returns An async iterable of stream chunks
210
+ */
211
+ async *createStreamingResponse(prompt: string, options?: { timeout?: number }): AsyncIterable<StreamChunk> {
212
+ const requestId = generateRequestId();
213
+ const startTime = Date.now();
214
+ const effectiveTimeout = options?.timeout ?? this.timeout;
215
+
216
+ this.logger.log('createStreamingResponse called', {
217
+ modelUsed: this.model,
218
+ requestId,
219
+ timeout: effectiveTimeout,
220
+ });
221
+
222
+ try {
223
+ // Create a client with the effective timeout if different from instance timeout
224
+ const client = effectiveTimeout !== this.timeout
225
+ ? new Anthropic({
226
+ apiKey: this.client.apiKey,
227
+ timeout: effectiveTimeout,
228
+ maxRetries: this.maxRetries,
229
+ })
230
+ : this.client;
231
+
232
+ const stream = client.messages.stream({
233
+ model: this.model,
234
+ max_tokens: ANTHROPIC_MAX_TOKENS,
235
+ system: DEFAULT_SYSTEM_PROMPT,
236
+ messages: [
237
+ {
238
+ role: 'user',
239
+ content: prompt,
240
+ },
241
+ ],
242
+ });
243
+
244
+ let accumulatedText = '';
245
+
246
+ for await (const chunk of stream) {
247
+ if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
248
+ const text = chunk.delta.text;
249
+ accumulatedText += text;
250
+
251
+ yield {
252
+ text,
253
+ isComplete: false,
254
+ accumulatedText,
255
+ };
256
+ }
257
+ }
258
+
259
+ // Get final message with usage data
260
+ const finalMessage = await stream.finalMessage();
261
+ let usage: StreamChunk['usage'] | undefined;
262
+
263
+ if (finalMessage.usage) {
264
+ usage = {
265
+ promptTokens: finalMessage.usage.input_tokens,
266
+ completionTokens: finalMessage.usage.output_tokens,
267
+ totalTokens: finalMessage.usage.input_tokens + finalMessage.usage.output_tokens,
268
+ };
269
+ }
270
+
271
+ // Final chunk with usage
272
+ const elapsedMs = Date.now() - startTime;
273
+ this.logger.log(`Streaming completed in ${elapsedMs}ms`);
274
+ if (usage) {
275
+ this.logger.logUsage(usage);
276
+ }
277
+
278
+ yield {
279
+ text: '',
280
+ isComplete: true,
281
+ accumulatedText,
282
+ usage,
283
+ };
284
+ } catch (error) {
285
+ const elapsedMs = Date.now() - startTime;
286
+ const context: LlmErrorContext = {
287
+ clientType: 'anthropic',
288
+ model: this.model,
289
+ elapsedMs,
290
+ timeoutMs: effectiveTimeout,
291
+ operation: 'createStreamingResponse',
292
+ requestId,
293
+ metadata: {
294
+ promptSize: prompt.length,
295
+ },
296
+ };
297
+
298
+ const wrappedError = wrapSdkError(error, context);
299
+ this.logger.logError('Streaming failed', wrappedError, wrappedError.context);
300
+ throw wrappedError;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Transforms reasoning effort level into Anthropic's thinking mode configuration
306
+ *
307
+ * @param reasoningEffort The normalized reasoning effort level
308
+ * @returns Thinking mode object with appropriate budget_tokens, or undefined if not needed
309
+ *
310
+ * Budget guidelines based on Anthropic's documentation:
311
+ * - none: extended thinking disabled (no thinking block sent)
312
+ * - low: 1,024 tokens (minimum threshold for basic reasoning)
313
+ * - medium: 8,192 tokens (moderate complexity tasks)
314
+ * - high: 16,384 tokens (complex tasks requiring comprehensive reasoning)
315
+ *
316
+ * @see https://docs.claude.com/en/docs/build-with-claude/extended-thinking
317
+ */
318
+ private getThinkingConfig(
319
+ reasoningEffort?: ReasoningEffortLevel,
320
+ ): { type: 'enabled'; budget_tokens: number } | undefined {
321
+ // Anthropic has no "off" switch — extended thinking is opt-in, so both an
322
+ // omitted effort and an explicit 'none' mean "send no thinking block".
323
+ if (!reasoningEffort || reasoningEffort === 'none') {
324
+ return undefined;
325
+ }
326
+
327
+ const budgetMap = {
328
+ low: 1024, // Minimum budget for basic reasoning
329
+ medium: 8192, // Moderate complexity tasks
330
+ high: 16384, // Complex tasks with comprehensive reasoning
331
+ };
332
+
333
+ return {
334
+ type: 'enabled',
335
+ budget_tokens: budgetMap[reasoningEffort],
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Creates a structured response using Anthropic for generation and OpenAI for formatting
341
+ *
342
+ * Since Anthropic doesn't support structured outputs, this method:
343
+ * 1. Generates content with Anthropic (with optional extended thinking)
344
+ * 2. Passes it to OpenAI's createStructuredResponse for formatting and validation
345
+ *
346
+ * @param options Configuration options
347
+ * @param options.prompt The prompt to send to the model
348
+ * @param options.schema The Zod schema to validate the response against
349
+ * @param options.formatGuidance Optional guidance for formatting the response
350
+ * @param options.reasoningEffort Normalized reasoning effort level ('none' | 'low' | 'medium' | 'high') -
351
+ * 'low' | 'medium' | 'high' enable extended thinking mode; 'none' (or omitted) leaves it off
352
+ * @param options.maxAttempts Maximum number of retry attempts (default: 1, no retries)
353
+ * @param options.logExecutionTime Whether to log execution time warnings (default: false)
354
+ * @param options.responseInstructions Additional instructions to append to the prompt (deprecated, use formatGuidance)
355
+ * @param options.useWebSearch Whether to enable web search for this request (default: false)
356
+ * @param options.stream Whether to use streaming for the generation phase (default: true)
357
+ * @param options.timeout Request timeout in milliseconds (overrides client default)
358
+ * @returns The structured and validated response according to the provided schema
359
+ * @throws Error if no OpenAI formatter is configured
360
+ */
361
+ async createStructuredResponse<T extends z.ZodType>({
362
+ prompt,
363
+ schema,
364
+ formatGuidance,
365
+ reasoningEffort,
366
+ maxAttempts = 3,
367
+ logExecutionTime = false,
368
+ responseInstructions,
369
+ useWebSearch = false,
370
+ stream = true,
371
+ timeout,
372
+ }: {
373
+ prompt: string;
374
+ schema: T;
375
+ formatGuidance?: string;
376
+ reasoningEffort?: ReasoningEffortLevel;
377
+ maxAttempts?: number;
378
+ logExecutionTime?: boolean;
379
+ responseInstructions?: string;
380
+ useWebSearch?: boolean;
381
+ stream?: boolean;
382
+ timeout?: number;
383
+ }): Promise<z.infer<T>> {
384
+ if (!this.formatterClient) {
385
+ throw new Error(
386
+ 'OpenAI formatter not configured. Anthropic does not support structured outputs natively. ' +
387
+ 'Please provide an OpenAI API key when creating the AnthropicClient, or set OPENAI_API_KEY environment variable.',
388
+ );
389
+ }
390
+
391
+ // Handle deprecated responseInstructions parameter
392
+ const effectiveFormatGuidance =
393
+ responseInstructions ?
394
+ formatGuidance ? `${formatGuidance}\n\n${responseInstructions}`
395
+ : responseInstructions
396
+ : formatGuidance;
397
+
398
+ const effectiveTimeout = timeout ?? this.timeout;
399
+ this._lastUsage = null;
400
+ const requestId = generateRequestId();
401
+ let attempts = 0;
402
+ let lastError: unknown;
403
+
404
+ // Build tools array if web search is enabled
405
+ const tools = useWebSearch ? [WEB_SEARCH_TOOLS.ANTHROPIC] : undefined;
406
+
407
+ // Get thinking configuration based on reasoning effort
408
+ const thinking = this.getThinkingConfig(reasoningEffort);
409
+
410
+ // Calculate max_tokens: must be greater than thinking budget
411
+ // If thinking is enabled, we need max_tokens > budget_tokens
412
+ // Otherwise, use the default ANTHROPIC_MAX_TOKENS
413
+ const maxTokens = thinking ? Math.max(ANTHROPIC_MAX_TOKENS, thinking.budget_tokens + 4096) : ANTHROPIC_MAX_TOKENS;
414
+
415
+ // Create a client with the effective timeout if different from instance timeout
416
+ const client = effectiveTimeout !== this.timeout
417
+ ? new Anthropic({
418
+ apiKey: this.client.apiKey,
419
+ timeout: effectiveTimeout,
420
+ maxRetries: this.maxRetries,
421
+ })
422
+ : this.client;
423
+
424
+ while (attempts < maxAttempts) {
425
+ attempts++;
426
+ const startTime = Date.now();
427
+
428
+ try {
429
+ this.logger.log('createStructuredResponse called', {
430
+ modelUsed: this.model,
431
+ reasoningEffort,
432
+ stream,
433
+ attempt: `${attempts}/${maxAttempts}`,
434
+ requestId,
435
+ timeout: effectiveTimeout,
436
+ });
437
+
438
+ let textContent: string;
439
+
440
+ if (stream) {
441
+ // Step 1: Generate content with Anthropic using streaming for observability
442
+ this.logger.log('Using streaming generation for observability');
443
+ let accumulatedText = '';
444
+ let lastLoggedLength = 0;
445
+
446
+ const messageStream = client.messages.stream({
447
+ model: this.model,
448
+ max_tokens: maxTokens,
449
+ system: DEFAULT_SYSTEM_PROMPT,
450
+ messages: [
451
+ {
452
+ role: 'user',
453
+ content: prompt,
454
+ },
455
+ ],
456
+ ...(tools && { tools }),
457
+ ...(thinking && { thinking }),
458
+ });
459
+
460
+ for await (const chunk of messageStream) {
461
+ if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
462
+ const text = chunk.delta.text;
463
+ accumulatedText += text;
464
+
465
+ // Log progress every 100 characters for observability
466
+ if (accumulatedText.length - lastLoggedLength >= 100) {
467
+ this.logger.log(`Streaming progress: ${accumulatedText.length} chars`);
468
+ lastLoggedLength = accumulatedText.length;
469
+ }
470
+ }
471
+ }
472
+
473
+ this.logger.log(`Streaming completed, total: ${accumulatedText.length} chars`);
474
+ textContent = accumulatedText;
475
+
476
+ // Capture usage from the stream's final message
477
+ const finalMessage = await messageStream.finalMessage();
478
+ if (finalMessage.usage) {
479
+ const promptTokens = finalMessage.usage.input_tokens ?? 0;
480
+ const completionTokens = finalMessage.usage.output_tokens ?? 0;
481
+ const cachedTokens: number | undefined = (finalMessage.usage as any).cache_read_input_tokens ?? undefined;
482
+ this._lastUsage = {
483
+ promptTokens,
484
+ completionTokens,
485
+ totalTokens: promptTokens + completionTokens,
486
+ cachedTokens,
487
+ model: this.model,
488
+ cost: calculateUsageCost(this.model, promptTokens, completionTokens, cachedTokens),
489
+ };
490
+ }
491
+ } else {
492
+ // Non-streaming path
493
+ const anthropicResponse = await client.messages.create({
494
+ model: this.model,
495
+ max_tokens: maxTokens,
496
+ system: DEFAULT_SYSTEM_PROMPT,
497
+ messages: [
498
+ {
499
+ role: 'user',
500
+ content: prompt,
501
+ },
502
+ ],
503
+ ...(tools && { tools }),
504
+ ...(thinking && { thinking }),
505
+ });
506
+
507
+ // Extract text content - when thinking mode is enabled, response may have multiple content blocks
508
+ // Find the text block (skip thinking blocks)
509
+ const textBlock = anthropicResponse.content.find((block) => block.type === 'text');
510
+ if (!textBlock || textBlock.type !== 'text') {
511
+ // Log the actual response structure for debugging
512
+ this.logger.logError('Unexpected response structure', undefined, {
513
+ contentBlocks: anthropicResponse.content.map((block) => ({ type: block.type })),
514
+ });
515
+ throw new Error('Expected text response from Anthropic');
516
+ }
517
+
518
+ textContent = textBlock.text;
519
+
520
+ // Capture usage from the non-streaming response
521
+ if (anthropicResponse.usage) {
522
+ const promptTokens = anthropicResponse.usage.input_tokens ?? 0;
523
+ const completionTokens = anthropicResponse.usage.output_tokens ?? 0;
524
+ const cachedTokens: number | undefined = (anthropicResponse.usage as any).cache_read_input_tokens ?? undefined;
525
+ this._lastUsage = {
526
+ promptTokens,
527
+ completionTokens,
528
+ totalTokens: promptTokens + completionTokens,
529
+ cachedTokens,
530
+ model: this.model,
531
+ cost: calculateUsageCost(this.model, promptTokens, completionTokens, cachedTokens),
532
+ };
533
+ }
534
+ }
535
+
536
+ this.logger.logResponsePreview(textContent);
537
+
538
+ // Step 2: Use OpenAI to format the response into the required schema
539
+ const formattedResponse = await this.formatterClient.createStructuredResponse({
540
+ prompt: textContent,
541
+ schema,
542
+ formatGuidance: effectiveFormatGuidance,
543
+ reasoningEffort: 'low', // Formatting doesn't need high reasoning
544
+ maxAttempts, // Pass through retry logic to formatter
545
+ logExecutionTime: false, // We'll log our own execution time
546
+ stream: false, // Don't stream the formatting step, only the generation
547
+ });
548
+
549
+ // Log execution time
550
+ const executionTime = Date.now() - startTime;
551
+ if (logExecutionTime || this.logger.isEnabled()) {
552
+ this.logger.logExecutionTime('createStructuredResponse', executionTime);
553
+ }
554
+
555
+ return formattedResponse;
556
+ } catch (error) {
557
+ lastError = error;
558
+ const elapsedMs = Date.now() - startTime;
559
+
560
+ const context: LlmErrorContext = {
561
+ clientType: 'anthropic',
562
+ model: this.model,
563
+ elapsedMs,
564
+ timeoutMs: effectiveTimeout,
565
+ operation: 'createStructuredResponse',
566
+ requestId,
567
+ metadata: {
568
+ promptSize: prompt.length,
569
+ schemaComplexity: typeof schema,
570
+ attempt: attempts,
571
+ maxAttempts,
572
+ reasoningEffort,
573
+ },
574
+ };
575
+
576
+ const wrappedError = wrapSdkError(error, context);
577
+ const retryable = isRetryableError(wrappedError);
578
+
579
+ this.logger.log(`Failed attempt ${attempts}/${maxAttempts}`, {
580
+ error: wrappedError.message,
581
+ errorType: wrappedError.name,
582
+ retryable,
583
+ elapsedMs,
584
+ ...(wrappedError.context.metadata?.errorCode && { errorCode: wrappedError.context.metadata.errorCode }),
585
+ ...(wrappedError.context.metadata?.providerRequestId && { providerRequestId: wrappedError.context.metadata.providerRequestId }),
586
+ });
587
+
588
+ if (!retryable || attempts === maxAttempts) {
589
+ this.logger.logError(
590
+ `createStructuredResponse failed${retryable ? ' after all attempts' : ' (non-retryable)'}`,
591
+ wrappedError,
592
+ wrappedError.context,
593
+ );
594
+ throw wrappedError;
595
+ }
596
+
597
+ // Wait before retrying (exponential backoff)
598
+ const backoffMs = Math.min(1000 * Math.pow(2, attempts - 1), 10000);
599
+ this.logger.log(`Retrying after ${backoffMs}ms backoff (attempt ${attempts + 1}/${maxAttempts})...`);
600
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
601
+ }
602
+ }
603
+
604
+ throw lastError || new Error('Failed to get structured response from LLM');
605
+ }
606
+
607
+ /**
608
+ * Process a batch of items with parallel processing
609
+ */
610
+ async processBatchWithLLM<T, R>({
611
+ items,
612
+ processFn,
613
+ batchSize = 5,
614
+ }: {
615
+ items: T[];
616
+ processFn: (batch: T[]) => Promise<R[]>;
617
+ batchSize?: number;
618
+ }): Promise<R[]> {
619
+ const batches: T[][] = [];
620
+ for (let i = 0; i < items.length; i += batchSize) {
621
+ batches.push(items.slice(i, i + batchSize));
622
+ }
623
+
624
+ const results = await Promise.all(batches.map(processFn));
625
+ return results.flat();
626
+ }
627
+
628
+ /**
629
+ * Generates an embedding vector for the provided text
630
+ * Note: Anthropic doesn't provide embeddings API yet
631
+ * This would need to be implemented using a different service
632
+ */
633
+ async generateEmbedding(_value: string): Promise<number[]> {
634
+ throw new Error('Anthropic does not support embeddings. Use OpenAI for embeddings.');
635
+ }
636
+ }