@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,802 @@
1
+ import { OpenRouter } from '@openrouter/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_SYSTEM_PROMPT } from '../models';
11
+ import { calculateUsageCost } from '../pricing';
12
+ import { DebugLogger } from '../utils/debug';
13
+ import {
14
+ generateRequestId,
15
+ wrapSdkError,
16
+ isRetryableError,
17
+ LlmOutputTruncatedError,
18
+ LlmJsonParseError,
19
+ type LlmErrorContext,
20
+ } from '../utils/errors';
21
+ import { normalizeNullStrings } from '../utils/normalize-null-strings';
22
+ import { resolveRefs } from '../utils/resolve-refs';
23
+ import { stripJsonArtifacts } from '../utils/strip-json-artifacts';
24
+
25
+ /**
26
+ * Post-processes a JSON Schema to enforce strict mode for OpenAI/OpenRouter structured outputs.
27
+ * Recursively finds all object schemas and adds `additionalProperties: false` plus ensures all
28
+ * property keys are listed in `required`.
29
+ */
30
+ function toStrictJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
31
+ function processNode(node: unknown): unknown {
32
+ if (node === null || typeof node !== 'object') {
33
+ return node;
34
+ }
35
+
36
+ if (Array.isArray(node)) {
37
+ return node.map(processNode);
38
+ }
39
+
40
+ const obj = node as Record<string, unknown>;
41
+ const result: Record<string, unknown> = {};
42
+
43
+ for (const [key, value] of Object.entries(obj)) {
44
+ result[key] = processNode(value);
45
+ }
46
+
47
+ const isObjectSchema =
48
+ result['type'] === 'object' || ('properties' in result && result['properties'] !== null);
49
+
50
+ if (isObjectSchema) {
51
+ result['additionalProperties'] = false;
52
+ const properties = result['properties'];
53
+ if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) {
54
+ const allKeys = Object.keys(properties as Record<string, unknown>);
55
+ const existingRequired = Array.isArray(result['required']) ? (result['required'] as string[]) : [];
56
+ const requiredSet = new Set([...existingRequired, ...allKeys]);
57
+ result['required'] = Array.from(requiredSet);
58
+ }
59
+ }
60
+
61
+ return result;
62
+ }
63
+
64
+ return processNode(schema) as Record<string, unknown>;
65
+ }
66
+
67
+ export interface OpenRouterClientConfig extends Omit<BaseLlmClientConfig, 'model'> {
68
+ /**
69
+ * The model to use (e.g., 'openai/gpt-4', 'anthropic/claude-3-opus')
70
+ * Defaults to 'google/gemini-2.5-flash-lite-preview-09-2025' if not specified
71
+ */
72
+ model?: string;
73
+
74
+ /**
75
+ * @deprecated OpenAI formatter client is no longer needed. OpenRouter now uses native structured outputs.
76
+ * This parameter is kept for backward compatibility but has no effect.
77
+ */
78
+ openaiApiKey?: string;
79
+ }
80
+
81
+ /**
82
+ * Client for interacting with OpenRouter's API with enhanced functionality
83
+ * for structured responses, batch processing, and multi-provider model access.
84
+ * Implements the unified LlmClientInterface.
85
+ *
86
+ * OpenRouter provides access to 300+ models across multiple providers with
87
+ * zero-downtime routing (ZDR) and flexible provider selection.
88
+ *
89
+ * Uses OpenRouter's native structured outputs feature for reliable JSON generation.
90
+ */
91
+ type OpenRouterRetryConfig =
92
+ | { strategy: 'none' }
93
+ | { strategy: 'backoff'; retryConnectionErrors?: boolean };
94
+
95
+ export class OpenRouterClient implements LlmClientInterface {
96
+ private client: OpenRouter;
97
+ private apiKey: string;
98
+ private model: string;
99
+ private logger: DebugLogger;
100
+ private timeout: number;
101
+ private retryConfig: OpenRouterRetryConfig;
102
+ private _lastUsage: LlmTokenUsage | null = null;
103
+
104
+ get lastUsage(): LlmTokenUsage | null {
105
+ return this._lastUsage;
106
+ }
107
+
108
+ /**
109
+ * Creates a new OpenRouterClient instance
110
+ *
111
+ * @param config Configuration options
112
+ * @throws Error if the API key is not configured
113
+ */
114
+ constructor(config: OpenRouterClientConfig) {
115
+ // Use config timeout or default to 120 seconds (2 minutes)
116
+ // OpenRouter acts as a proxy, so we use a more conservative default
117
+ this.timeout = config.timeout ?? 120000;
118
+ this.apiKey = config.apiKey;
119
+ // The OpenRouter SDK's default backoff retries can mask `timeoutMs` for
120
+ // small values and consume long deadlines. Honor maxRetries=0 by disabling
121
+ // retries entirely; otherwise let the SDK use its default backoff.
122
+ this.retryConfig =
123
+ config.maxRetries === 0
124
+ ? { strategy: 'none' }
125
+ : { strategy: 'backoff', retryConnectionErrors: true };
126
+
127
+ this.client = new OpenRouter({
128
+ apiKey: config.apiKey,
129
+ timeoutMs: this.timeout,
130
+ retryConfig: this.retryConfig,
131
+ });
132
+ this.model = config.model || 'google/gemini-2.5-flash-lite-preview-09-2025';
133
+ this.logger = new DebugLogger('OpenRouterClient', { enabled: config.debug });
134
+
135
+ // Validate configuration on instantiation
136
+ this.validateConfiguration();
137
+ }
138
+
139
+ /**
140
+ * Validates that the client is properly configured
141
+ * @returns true if valid, throws an error with details if not
142
+ */
143
+ validateConfiguration(): boolean {
144
+ // Note: The OpenRouter SDK doesn't expose apiKey directly,
145
+ // so we assume it's valid if the client was constructed
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Returns true when the configured model is a Google/Gemini model,
151
+ * which requires $ref/$defs inlining and anyOf nullable conversion.
152
+ */
153
+ private isGeminiModel(): boolean {
154
+ return this.model.startsWith('google/');
155
+ }
156
+
157
+ /**
158
+ * Converts a Zod schema to OpenRouter-compatible JSON Schema format
159
+ *
160
+ * @param schema The Zod schema to convert
161
+ * @param name The name for the schema (used by OpenRouter)
162
+ * @param description Optional description for the schema
163
+ * @returns OpenRouter-compatible JSON schema config
164
+ */
165
+ private zodToOpenRouterSchema<T extends z.ZodType>(
166
+ schema: T,
167
+ name: string,
168
+ description?: string
169
+ ): { name: string; schema: any; strict: boolean; description?: string } {
170
+ try {
171
+ let jsonSchema = toStrictJsonSchema(z.toJSONSchema(schema) as Record<string, unknown>);
172
+
173
+ // Gemini models do not support $ref/$defs in JSON Schema.
174
+ // Inline all references and convert anyOf nullable patterns.
175
+ if (this.isGeminiModel()) {
176
+ this.logger.log('Resolving $ref/$defs for Gemini model compatibility', {
177
+ model: this.model,
178
+ });
179
+ jsonSchema = resolveRefs(jsonSchema);
180
+ }
181
+
182
+ return {
183
+ name,
184
+ schema: jsonSchema,
185
+ strict: true,
186
+ description,
187
+ };
188
+ } catch (error) {
189
+ this.logger.logWarning('Failed to convert Zod schema to JSON Schema', { error });
190
+ throw new Error(`Schema conversion failed: ${error instanceof Error ? error.message : String(error)}`);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Creates a response from OpenRouter's API and returns the text content
196
+ * Uses the chat API which is stateless when used without conversation history.
197
+ *
198
+ * @param prompt The prompt to send to the model
199
+ * @param options Optional configuration
200
+ * @param options.timeout Request timeout in milliseconds (overrides client default)
201
+ * @param options.skipArtifactStripping Set to true to skip LLM output artifact sanitization (default: false)
202
+ * @returns The text content as a string, sanitized by `stripJsonArtifacts` unless opted out
203
+ */
204
+ async createResponse(
205
+ prompt: string,
206
+ options?: { timeout?: number; skipArtifactStripping?: boolean },
207
+ ): Promise<string> {
208
+ const response = await this.createRawResponse(prompt, options);
209
+ const content = this.extractContentFromResponse(response);
210
+ if (options?.skipArtifactStripping) {
211
+ return content;
212
+ }
213
+ const result = stripJsonArtifacts(content);
214
+ if (result.wasModified) {
215
+ this.logger.log('stripJsonArtifacts modified response', { removedPatterns: result.removedPatterns });
216
+ }
217
+ return result.sanitized;
218
+ }
219
+
220
+ /**
221
+ * Creates a raw response from OpenRouter's API and returns the full response object
222
+ * Use this when you need access to metadata like usage stats, finish reason, etc.
223
+ * Uses the chat API which is stateless when used without conversation history.
224
+ *
225
+ * @param prompt The prompt to send to the model
226
+ * @param options Optional configuration
227
+ * @param options.timeout Request timeout in milliseconds (overrides client default)
228
+ * @returns The complete chat completion response from OpenRouter
229
+ */
230
+ async createRawResponse(prompt: string, options?: { timeout?: number }): Promise<unknown> {
231
+ const effectiveTimeout = options?.timeout ?? this.timeout;
232
+ const requestId = generateRequestId();
233
+ const startTime = Date.now();
234
+
235
+ try {
236
+ // Create a client with the effective timeout if different from instance timeout
237
+ const client = effectiveTimeout !== this.timeout
238
+ ? new OpenRouter({
239
+ apiKey: this.apiKey,
240
+ timeoutMs: effectiveTimeout,
241
+ retryConfig: this.retryConfig,
242
+ })
243
+ : this.client;
244
+
245
+ const response = await client.chat.send({
246
+ chatRequest: {
247
+ model: this.model,
248
+ messages: [
249
+ { role: 'system', content: DEFAULT_SYSTEM_PROMPT },
250
+ { role: 'user', content: prompt },
251
+ ],
252
+ stream: false,
253
+ },
254
+ });
255
+
256
+ return response;
257
+ } catch (error) {
258
+ const elapsedMs = Date.now() - startTime;
259
+ const context: LlmErrorContext = {
260
+ clientType: 'openrouter',
261
+ model: this.model,
262
+ elapsedMs,
263
+ timeoutMs: effectiveTimeout,
264
+ operation: 'createRawResponse',
265
+ requestId,
266
+ metadata: {
267
+ promptSize: prompt.length,
268
+ },
269
+ };
270
+
271
+ const wrappedError = wrapSdkError(error, context);
272
+ this.logger.logError('createRawResponse failed', wrappedError, wrappedError.context);
273
+ throw wrappedError;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Extracts text content from a raw OpenRouter response object
279
+ * OpenRouter's chat API returns responses with a 'message.content' field in choices
280
+ *
281
+ * @param response The raw response from OpenRouter
282
+ * @returns The text content as a string
283
+ * @throws Error if there is no content in the response
284
+ */
285
+ private extractContentFromResponse(response: unknown): string {
286
+ const typedResponse = response as any;
287
+ const content = typedResponse.choices?.[0]?.message?.content;
288
+ if (!content) {
289
+ throw new Error('No content in OpenRouter response');
290
+ }
291
+ return typeof content === 'string' ? content : JSON.stringify(content);
292
+ }
293
+
294
+ /**
295
+ * Creates a streaming response from OpenRouter's API
296
+ * Returns an async iterator that yields chunks of text as they arrive
297
+ *
298
+ * @param prompt The prompt to send to the model
299
+ * @param options Optional configuration
300
+ * @param options.timeout Request timeout in milliseconds (overrides client default)
301
+ * @returns An async iterable of stream chunks
302
+ */
303
+ async *createStreamingResponse(
304
+ prompt: string,
305
+ options?: { timeout?: number }
306
+ ): AsyncIterable<StreamChunk> {
307
+ yield* this.createStreamingResponseInternal({ prompt, timeout: options?.timeout });
308
+ }
309
+
310
+ /**
311
+ * Internal streaming response method with support for structured outputs
312
+ *
313
+ * @param options.prompt The prompt to send to the model
314
+ * @param options.responseFormat Optional structured output format configuration
315
+ * @param options.timeout Optional timeout in milliseconds
316
+ * @param options.systemPrompt Optional custom system prompt (defaults to DEFAULT_SYSTEM_PROMPT)
317
+ * @param options.reasoningEffort Optional reasoning effort; sent as `reasoning.effort` only when defined
318
+ * @returns An async iterable of stream chunks
319
+ */
320
+ private async *createStreamingResponseInternal({
321
+ prompt,
322
+ responseFormat,
323
+ timeout,
324
+ systemPrompt = DEFAULT_SYSTEM_PROMPT,
325
+ reasoningEffort,
326
+ }: {
327
+ prompt: string;
328
+ responseFormat?: {
329
+ type: 'json_schema';
330
+ jsonSchema: {
331
+ name: string;
332
+ schema: any;
333
+ strict?: boolean;
334
+ description?: string;
335
+ };
336
+ };
337
+ timeout?: number;
338
+ systemPrompt?: string;
339
+ reasoningEffort?: ReasoningEffortLevel;
340
+ }): AsyncIterable<StreamChunk> {
341
+ const effectiveTimeout = timeout ?? this.timeout;
342
+ const requestId = generateRequestId();
343
+ const startTime = Date.now();
344
+
345
+ this.logger.log('createStreamingResponse called', {
346
+ modelUsed: this.model,
347
+ requestId,
348
+ hasResponseFormat: !!responseFormat,
349
+ timeout: effectiveTimeout,
350
+ });
351
+
352
+ try {
353
+ // Create a client with the effective timeout if different from instance timeout
354
+ const client = effectiveTimeout !== this.timeout
355
+ ? new OpenRouter({
356
+ apiKey: this.apiKey,
357
+ timeoutMs: effectiveTimeout,
358
+ retryConfig: this.retryConfig,
359
+ })
360
+ : this.client;
361
+
362
+ const stream = await client.chat.send({
363
+ chatRequest: {
364
+ model: this.model,
365
+ messages: [
366
+ { role: 'system', content: systemPrompt },
367
+ { role: 'user', content: prompt },
368
+ ],
369
+ stream: true,
370
+ ...(responseFormat && { responseFormat }),
371
+ ...(reasoningEffort !== undefined && { reasoning: { effort: reasoningEffort } }),
372
+ },
373
+ });
374
+
375
+ let accumulatedText = '';
376
+ let usage: StreamChunk['usage'] | undefined;
377
+ let finishReason: string | undefined;
378
+
379
+ for await (const chunk of stream as any) {
380
+ const content = chunk.choices?.[0]?.delta?.content;
381
+ if (content) {
382
+ accumulatedText += content;
383
+
384
+ yield {
385
+ text: content,
386
+ isComplete: false,
387
+ accumulatedText,
388
+ };
389
+ }
390
+
391
+ // Capture finish_reason from chunks (present in the final chunk)
392
+ const chunkFinishReason = chunk.choices?.[0]?.finish_reason;
393
+ if (chunkFinishReason) {
394
+ finishReason = chunkFinishReason;
395
+ }
396
+
397
+ // Capture usage from chunk when present (OpenRouter includes it in final chunk)
398
+ // Note: OpenRouter SDK returns camelCase properties (promptTokens, completionTokens, totalTokens)
399
+ if (chunk.usage) {
400
+ usage = {
401
+ promptTokens: chunk.usage.promptTokens,
402
+ completionTokens: chunk.usage.completionTokens,
403
+ totalTokens: chunk.usage.totalTokens,
404
+ };
405
+ }
406
+ }
407
+
408
+ // Final chunk with usage and finish reason
409
+ const elapsedMs = Date.now() - startTime;
410
+ this.logger.log(`Streaming completed in ${elapsedMs}ms`, { finishReason });
411
+ if (usage) {
412
+ this.logger.logUsage(usage);
413
+ }
414
+
415
+ yield {
416
+ text: '',
417
+ isComplete: true,
418
+ accumulatedText,
419
+ usage,
420
+ finishReason,
421
+ };
422
+ } catch (error) {
423
+ const elapsedMs = Date.now() - startTime;
424
+ const context: LlmErrorContext = {
425
+ clientType: 'openrouter',
426
+ model: this.model,
427
+ elapsedMs,
428
+ timeoutMs: effectiveTimeout,
429
+ operation: 'createStreamingResponse',
430
+ requestId,
431
+ metadata: {
432
+ promptSize: prompt.length,
433
+ hasResponseFormat: !!responseFormat,
434
+ },
435
+ };
436
+
437
+ const wrappedError = wrapSdkError(error, context);
438
+ this.logger.logError('Streaming failed', wrappedError, wrappedError.context);
439
+ throw wrappedError;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Creates a structured response using OpenRouter's native structured outputs feature.
445
+ * Uses Zod schema conversion to JSON Schema and OpenRouter's response_format parameter
446
+ * to enforce schema compliance at the model level.
447
+ *
448
+ * @param options Configuration options for the structured response
449
+ * @param options.prompt The prompt to send to the model
450
+ * @param options.schema The Zod schema to validate the response against
451
+ * @param options.formatGuidance Optional guidance for formatting the response
452
+ * @param options.reasoningEffort Normalized reasoning effort level ('none' | 'low' | 'medium' | 'high').
453
+ * Sent to OpenRouter as `reasoning.effort` only when explicitly provided; when omitted no
454
+ * `reasoning` field is transmitted and the model/provider default applies. Pass 'none' to
455
+ * turn a reasoning model's thinking off.
456
+ * @param options.maxAttempts Maximum number of retry attempts (default: 1, no retries)
457
+ * @param options.logExecutionTime Whether to log execution time warnings (default: false)
458
+ * @param options.responseInstructions Additional instructions to append to the prompt (deprecated, use formatGuidance)
459
+ * @param options.useWebSearch Whether to enable web search for this request (default: false)
460
+ * @param options.stream Whether to use streaming for the generation phase (default: true)
461
+ * @param options.timeout Request timeout in milliseconds (overrides client default)
462
+ * @returns The structured and validated response according to the provided schema
463
+ * @throws Error if the response cannot be parsed or if the model refuses to respond
464
+ */
465
+ async createStructuredResponse<T extends z.ZodType>({
466
+ prompt,
467
+ schema,
468
+ formatGuidance,
469
+ reasoningEffort,
470
+ maxAttempts = 3,
471
+ logExecutionTime = false,
472
+ responseInstructions,
473
+ stream = true,
474
+ timeout,
475
+ }: {
476
+ prompt: string;
477
+ schema: T;
478
+ formatGuidance?: string;
479
+ reasoningEffort?: ReasoningEffortLevel;
480
+ maxAttempts?: number;
481
+ logExecutionTime?: boolean;
482
+ responseInstructions?: string;
483
+ useWebSearch?: boolean;
484
+ stream?: boolean;
485
+ timeout?: number;
486
+ }): Promise<z.infer<T>> {
487
+ // Handle deprecated responseInstructions parameter
488
+ const effectiveFormatGuidance =
489
+ responseInstructions ?
490
+ formatGuidance ? `${formatGuidance}\n\n${responseInstructions}`
491
+ : responseInstructions
492
+ : formatGuidance;
493
+
494
+ this._lastUsage = null;
495
+
496
+ const effectiveTimeout = timeout ?? this.timeout;
497
+ const requestId = generateRequestId();
498
+ let attempts = 0;
499
+ let lastError: unknown;
500
+
501
+ // Create a client with the effective timeout if different from instance timeout
502
+ const client = effectiveTimeout !== this.timeout
503
+ ? new OpenRouter({
504
+ apiKey: this.apiKey,
505
+ timeoutMs: effectiveTimeout,
506
+ retryConfig: this.retryConfig,
507
+ })
508
+ : this.client;
509
+
510
+ while (attempts < maxAttempts) {
511
+ attempts++;
512
+ const startTime = Date.now();
513
+
514
+ try {
515
+ this.logger.log('createStructuredResponse called', {
516
+ modelUsed: this.model,
517
+ schemaType: typeof schema,
518
+ schemaConstructor: schema?.constructor?.name,
519
+ reasoningEffort,
520
+ attempt: `${attempts}/${maxAttempts}`,
521
+ usingNativeStructuredOutputs: true,
522
+ stream,
523
+ requestId,
524
+ timeout: effectiveTimeout,
525
+ });
526
+
527
+ // Convert Zod schema to OpenRouter JSON Schema format
528
+ const responseFormat = {
529
+ type: 'json_schema' as const,
530
+ jsonSchema: this.zodToOpenRouterSchema(
531
+ schema,
532
+ 'structuredResponse',
533
+ effectiveFormatGuidance
534
+ ),
535
+ };
536
+
537
+ // Build the system prompt with instructions
538
+ const systemPrompt =
539
+ DEFAULT_SYSTEM_PROMPT +
540
+ '\n\nRespond with valid JSON data matching the provided schema.' +
541
+ (effectiveFormatGuidance ? `\n\n${effectiveFormatGuidance}` : '');
542
+
543
+ let contentString: string;
544
+ let lastFinishReason: string | undefined;
545
+
546
+ if (stream) {
547
+ // Use streaming with structured output format
548
+ this.logger.log('Using streaming generation with native structured outputs');
549
+ let accumulatedText = '';
550
+ let lastLoggedLength = 0;
551
+ let finishReason: string | undefined;
552
+
553
+ for await (const chunk of this.createStreamingResponseInternal({
554
+ prompt,
555
+ responseFormat,
556
+ timeout: effectiveTimeout,
557
+ systemPrompt,
558
+ reasoningEffort,
559
+ })) {
560
+ if (!chunk.isComplete) {
561
+ accumulatedText += chunk.text;
562
+
563
+ // Log progress every 100 characters for observability
564
+ if (accumulatedText.length - lastLoggedLength >= 100) {
565
+ this.logger.log(`Streaming progress: ${accumulatedText.length} chars`);
566
+ lastLoggedLength = accumulatedText.length;
567
+ }
568
+ }
569
+
570
+ // Capture finish reason and usage from the final chunk
571
+ if (chunk.finishReason) {
572
+ finishReason = chunk.finishReason;
573
+ }
574
+ if (chunk.usage) {
575
+ const promptTokens = chunk.usage.promptTokens ?? 0;
576
+ const completionTokens = chunk.usage.completionTokens ?? 0;
577
+ this._lastUsage = {
578
+ promptTokens,
579
+ completionTokens,
580
+ totalTokens: chunk.usage.totalTokens ?? 0,
581
+ model: this.model,
582
+ cost: calculateUsageCost(this.model, promptTokens, completionTokens),
583
+ };
584
+ }
585
+ }
586
+
587
+ this.logger.log(`Streaming completed, total: ${accumulatedText.length} chars`);
588
+
589
+ // Check for output truncation before attempting to parse
590
+ if (finishReason === 'length') {
591
+ throw new LlmOutputTruncatedError(
592
+ {
593
+ clientType: 'openrouter',
594
+ model: this.model,
595
+ elapsedMs: Date.now() - startTime,
596
+ timeoutMs: effectiveTimeout,
597
+ operation: 'createStructuredResponse',
598
+ requestId,
599
+ metadata: {
600
+ promptSize: prompt.length,
601
+ contentLength: accumulatedText.length,
602
+ finishReason,
603
+ },
604
+ },
605
+ accumulatedText.length,
606
+ );
607
+ }
608
+
609
+ contentString = accumulatedText;
610
+ lastFinishReason = finishReason;
611
+ } else {
612
+ // Non-streaming path with structured outputs
613
+ const response = await client.chat.send({
614
+ chatRequest: {
615
+ model: this.model,
616
+ messages: [
617
+ { role: 'system', content: systemPrompt },
618
+ { role: 'user', content: prompt },
619
+ ],
620
+ stream: false,
621
+ responseFormat,
622
+ ...(reasoningEffort !== undefined && { reasoning: { effort: reasoningEffort } }),
623
+ },
624
+ });
625
+
626
+ const typedResponse = response as any;
627
+ const rawUsage = typedResponse.usage;
628
+ if (rawUsage) {
629
+ const promptTokens = rawUsage.prompt_tokens ?? rawUsage.promptTokens ?? 0;
630
+ const completionTokens = rawUsage.completion_tokens ?? rawUsage.completionTokens ?? 0;
631
+ this._lastUsage = {
632
+ promptTokens,
633
+ completionTokens,
634
+ totalTokens: rawUsage.total_tokens ?? rawUsage.totalTokens ?? 0,
635
+ model: this.model,
636
+ cost: calculateUsageCost(this.model, promptTokens, completionTokens),
637
+ };
638
+ }
639
+ this.logger.logUsage(rawUsage || {});
640
+
641
+ // Check for output truncation before attempting to parse
642
+ const finishReason = typedResponse.choices?.[0]?.finish_reason;
643
+ if (finishReason === 'length') {
644
+ const content = typedResponse.choices?.[0]?.message?.content ?? '';
645
+ throw new LlmOutputTruncatedError(
646
+ {
647
+ clientType: 'openrouter',
648
+ model: this.model,
649
+ elapsedMs: Date.now() - startTime,
650
+ timeoutMs: effectiveTimeout,
651
+ operation: 'createStructuredResponse',
652
+ requestId,
653
+ metadata: {
654
+ promptSize: prompt.length,
655
+ contentLength: typeof content === 'string' ? content.length : 0,
656
+ finishReason,
657
+ },
658
+ },
659
+ typeof content === 'string' ? content.length : 0,
660
+ );
661
+ }
662
+
663
+ contentString = this.extractContentFromResponse(response);
664
+ lastFinishReason = typedResponse.choices?.[0]?.finish_reason;
665
+ }
666
+
667
+ // Parse and validate the response
668
+ // OpenRouter should return valid JSON matching the schema
669
+ this.logger.log('Parsing and validating structured response');
670
+
671
+ let parsed: unknown;
672
+ try {
673
+ parsed = JSON.parse(contentString);
674
+ } catch (parseError) {
675
+ throw new LlmJsonParseError(
676
+ {
677
+ clientType: 'openrouter',
678
+ model: this.model,
679
+ elapsedMs: Date.now() - startTime,
680
+ timeoutMs: effectiveTimeout,
681
+ operation: 'createStructuredResponse',
682
+ requestId,
683
+ metadata: {
684
+ promptSize: prompt.length,
685
+ contentLength: contentString.length,
686
+ contentPreview: contentString.slice(-200),
687
+ finishReason: lastFinishReason,
688
+ },
689
+ },
690
+ contentString,
691
+ parseError as SyntaxError,
692
+ );
693
+ }
694
+
695
+ // Gemini models return the literal string "null" for nullable fields.
696
+ // Normalize before Zod validation to prevent silent data corruption.
697
+ if (this.isGeminiModel()) {
698
+ this.logger.log('Normalizing Gemini null-string values for nullable schema fields');
699
+ normalizeNullStrings(parsed, schema);
700
+ }
701
+
702
+ const validatedContent = schema.parse(parsed);
703
+
704
+ // Log execution time
705
+ const executionTime = Date.now() - startTime;
706
+ if (logExecutionTime || this.logger.isEnabled()) {
707
+ this.logger.logExecutionTime('createStructuredResponse', executionTime);
708
+ }
709
+
710
+ return validatedContent;
711
+ } catch (error) {
712
+ lastError = error;
713
+ const elapsedMs = Date.now() - startTime;
714
+
715
+ const context: LlmErrorContext = {
716
+ clientType: 'openrouter',
717
+ model: this.model,
718
+ elapsedMs,
719
+ timeoutMs: effectiveTimeout,
720
+ operation: 'createStructuredResponse',
721
+ requestId,
722
+ metadata: {
723
+ promptSize: prompt.length,
724
+ schemaComplexity: typeof schema,
725
+ attempt: attempts,
726
+ maxAttempts,
727
+ usingNativeStructuredOutputs: true,
728
+ },
729
+ };
730
+
731
+ const wrappedError = wrapSdkError(error, context);
732
+ const retryable = isRetryableError(error instanceof LlmJsonParseError ? error : wrappedError);
733
+
734
+ this.logger.log(`Failed attempt ${attempts}/${maxAttempts}`, {
735
+ error: wrappedError.message,
736
+ errorType: wrappedError.name,
737
+ retryable,
738
+ elapsedMs,
739
+ ...(wrappedError.context.metadata?.errorCode && { errorCode: wrappedError.context.metadata.errorCode }),
740
+ ...(wrappedError.context.metadata?.providerRequestId && { providerRequestId: wrappedError.context.metadata.providerRequestId }),
741
+ });
742
+
743
+ if (!retryable || attempts === maxAttempts) {
744
+ const reason = !retryable ? ' (non-retryable)' : ' after all attempts';
745
+ this.logger.logError(
746
+ `createStructuredResponse failed${reason}`,
747
+ wrappedError,
748
+ wrappedError.context,
749
+ );
750
+ throw error instanceof LlmJsonParseError || error instanceof LlmOutputTruncatedError
751
+ ? error
752
+ : wrappedError;
753
+ }
754
+
755
+ // Wait before retrying (exponential backoff)
756
+ const backoffMs = Math.min(1000 * Math.pow(2, attempts - 1), 10000);
757
+ this.logger.log(`Retrying after ${backoffMs}ms backoff (attempt ${attempts + 1}/${maxAttempts})...`);
758
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
759
+ }
760
+ }
761
+
762
+ throw lastError || new Error('Failed to get structured response from LLM');
763
+ }
764
+
765
+ /**
766
+ * Process a batch of items with an LLM using parallel processing
767
+ */
768
+ async processBatchWithLLM<T, R>({
769
+ items,
770
+ processFn,
771
+ batchSize = 5,
772
+ }: {
773
+ items: T[];
774
+ processFn: (batch: T[]) => Promise<R[]>;
775
+ batchSize?: number;
776
+ }): Promise<R[]> {
777
+ // Split items into batches
778
+ const batches: T[][] = [];
779
+ for (let i = 0; i < items.length; i += batchSize) {
780
+ batches.push(items.slice(i, i + batchSize));
781
+ }
782
+
783
+ // Process all batches in parallel
784
+ const results = await Promise.all(batches.map(processFn));
785
+
786
+ // Flatten the results array
787
+ return results.flat();
788
+ }
789
+
790
+ /**
791
+ * Generates an embedding vector for the provided text
792
+ *
793
+ * Note: OpenRouter does not have a native embeddings endpoint.
794
+ * This method throws an error and directs users to use OpenAI directly.
795
+ *
796
+ * @param _value The text to generate an embedding for
797
+ * @throws Error indicating embeddings are not supported
798
+ */
799
+ async generateEmbedding(_value: string): Promise<number[]> {
800
+ throw new Error('OpenRouter does not support embeddings. Use OpenAI client directly for embeddings.');
801
+ }
802
+ }