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