@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,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced error classes for LLM operations with rich debugging context
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface LlmErrorContext {
|
|
6
|
+
/**
|
|
7
|
+
* The client type that encountered the error
|
|
8
|
+
*/
|
|
9
|
+
clientType: 'openai' | 'anthropic' | 'openrouter';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The model being used
|
|
13
|
+
*/
|
|
14
|
+
model: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Time elapsed before error (in milliseconds)
|
|
18
|
+
*/
|
|
19
|
+
elapsedMs: number;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Timeout configuration (in milliseconds)
|
|
23
|
+
*/
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Operation that failed
|
|
28
|
+
*/
|
|
29
|
+
operation: string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Request correlation ID for tracking
|
|
33
|
+
*/
|
|
34
|
+
requestId?: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Additional metadata
|
|
38
|
+
*/
|
|
39
|
+
metadata?: {
|
|
40
|
+
promptSize?: number;
|
|
41
|
+
schemaComplexity?: string;
|
|
42
|
+
attempt?: number;
|
|
43
|
+
maxAttempts?: number;
|
|
44
|
+
[key: string]: any;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Base error class for all LLM-related errors with enhanced context
|
|
50
|
+
*/
|
|
51
|
+
export class LlmError extends Error {
|
|
52
|
+
public readonly context: LlmErrorContext;
|
|
53
|
+
public readonly originalError?: Error;
|
|
54
|
+
public readonly timestamp: string;
|
|
55
|
+
|
|
56
|
+
constructor(message: string, context: LlmErrorContext, originalError?: Error) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'LlmError';
|
|
59
|
+
this.context = context;
|
|
60
|
+
this.originalError = originalError;
|
|
61
|
+
this.timestamp = new Date().toISOString();
|
|
62
|
+
|
|
63
|
+
// Maintain proper stack trace
|
|
64
|
+
if (Error.captureStackTrace) {
|
|
65
|
+
Error.captureStackTrace(this, this.constructor);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Append original stack if available
|
|
69
|
+
if (originalError?.stack) {
|
|
70
|
+
this.stack = `${this.stack}\n\nCaused by: ${originalError.stack}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get a formatted error message with full context
|
|
76
|
+
*/
|
|
77
|
+
getDetailedMessage(): string {
|
|
78
|
+
const lines = [
|
|
79
|
+
`[${this.context.clientType.toUpperCase()}] ${this.message}`,
|
|
80
|
+
`Model: ${this.context.model}`,
|
|
81
|
+
`Operation: ${this.context.operation}`,
|
|
82
|
+
`Elapsed: ${this.context.elapsedMs}ms`,
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
if (this.context.timeoutMs) {
|
|
86
|
+
lines.push(`Timeout: ${this.context.timeoutMs}ms`);
|
|
87
|
+
const percentage = ((this.context.elapsedMs / this.context.timeoutMs) * 100).toFixed(1);
|
|
88
|
+
lines.push(`Progress: ${percentage}% of timeout`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (this.context.requestId) {
|
|
92
|
+
lines.push(`Request ID: ${this.context.requestId}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (this.context.metadata) {
|
|
96
|
+
if (this.context.metadata.attempt && this.context.metadata.maxAttempts) {
|
|
97
|
+
lines.push(`Attempt: ${this.context.metadata.attempt}/${this.context.metadata.maxAttempts}`);
|
|
98
|
+
}
|
|
99
|
+
if (this.context.metadata.promptSize) {
|
|
100
|
+
lines.push(`Prompt size: ${this.context.metadata.promptSize} chars`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (this.originalError) {
|
|
105
|
+
lines.push(`Original error: ${this.originalError.message}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join('\n ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get a JSON representation of the error for logging
|
|
113
|
+
*/
|
|
114
|
+
toJSON(): Record<string, any> {
|
|
115
|
+
return {
|
|
116
|
+
name: this.name,
|
|
117
|
+
message: this.message,
|
|
118
|
+
timestamp: this.timestamp,
|
|
119
|
+
context: this.context,
|
|
120
|
+
originalError: this.originalError
|
|
121
|
+
? {
|
|
122
|
+
name: this.originalError.name,
|
|
123
|
+
message: this.originalError.message,
|
|
124
|
+
stack: this.originalError.stack,
|
|
125
|
+
}
|
|
126
|
+
: undefined,
|
|
127
|
+
stack: this.stack,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Error thrown when a request times out
|
|
134
|
+
*/
|
|
135
|
+
export class LlmTimeoutError extends LlmError {
|
|
136
|
+
constructor(context: LlmErrorContext, originalError?: Error) {
|
|
137
|
+
const message = `Request timed out after ${context.elapsedMs}ms (timeout: ${context.timeoutMs}ms)`;
|
|
138
|
+
super(message, context, originalError);
|
|
139
|
+
this.name = 'LlmTimeoutError';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Error thrown when response validation fails
|
|
145
|
+
*/
|
|
146
|
+
export class LlmValidationError extends LlmError {
|
|
147
|
+
constructor(context: LlmErrorContext, originalError?: Error) {
|
|
148
|
+
const message = 'Response validation failed';
|
|
149
|
+
super(message, context, originalError);
|
|
150
|
+
this.name = 'LlmValidationError';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Details extracted from a provider error response.
|
|
156
|
+
*/
|
|
157
|
+
export interface LlmApiErrorDetails {
|
|
158
|
+
/** Human-readable reason from the provider (e.g. "Key limit exceeded (monthly limit)") */
|
|
159
|
+
providerMessage?: string;
|
|
160
|
+
/** Numeric or string error code from the provider */
|
|
161
|
+
providerCode?: string | number;
|
|
162
|
+
/** Raw response body as received (string or object) */
|
|
163
|
+
body?: unknown;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Error thrown when the API returns an error
|
|
168
|
+
*/
|
|
169
|
+
export class LlmApiError extends LlmError {
|
|
170
|
+
public readonly statusCode?: number;
|
|
171
|
+
public readonly providerMessage?: string;
|
|
172
|
+
public readonly providerCode?: string | number;
|
|
173
|
+
public readonly body?: unknown;
|
|
174
|
+
|
|
175
|
+
constructor(
|
|
176
|
+
context: LlmErrorContext,
|
|
177
|
+
statusCode?: number,
|
|
178
|
+
originalError?: Error,
|
|
179
|
+
details?: LlmApiErrorDetails,
|
|
180
|
+
) {
|
|
181
|
+
const base = statusCode ? `API error (status ${statusCode})` : 'API error';
|
|
182
|
+
const message =
|
|
183
|
+
details?.providerMessage ? `${base}: ${details.providerMessage}` : base;
|
|
184
|
+
super(message, context, originalError);
|
|
185
|
+
this.name = 'LlmApiError';
|
|
186
|
+
this.statusCode = statusCode;
|
|
187
|
+
this.providerMessage = details?.providerMessage;
|
|
188
|
+
this.providerCode = details?.providerCode;
|
|
189
|
+
this.body = details?.body;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Reads the `data` payload from an axios-style `error.response`, if present.
|
|
195
|
+
*/
|
|
196
|
+
function extractResponseData(response: unknown): unknown {
|
|
197
|
+
if (response === null || typeof response !== 'object') {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
return (response as Record<string, unknown>)['data'];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Extracts provider-specific error details from an unknown SDK error object.
|
|
205
|
+
*
|
|
206
|
+
* Priority order for providerMessage:
|
|
207
|
+
* 1. error.error?.message — OpenRouter typed subclass / OpenAI-ish
|
|
208
|
+
* 2. parsed error.body — OpenRouter base class (body is a JSON string)
|
|
209
|
+
* 3. error.body?.error?.message — body already an object
|
|
210
|
+
* 4. error.response?.data?.error?.message — axios-style
|
|
211
|
+
*
|
|
212
|
+
* Returns {} for non-object / null inputs.
|
|
213
|
+
*/
|
|
214
|
+
export function extractProviderError(error: unknown): LlmApiErrorDetails {
|
|
215
|
+
if (error === null || typeof error !== 'object') {
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const err = error as Record<string, unknown>;
|
|
220
|
+
|
|
221
|
+
// --- providerMessage resolution (priority order) ---
|
|
222
|
+
|
|
223
|
+
// 1. error.error?.message (OpenRouter typed subclass)
|
|
224
|
+
const errorField = err['error'];
|
|
225
|
+
if (
|
|
226
|
+
errorField !== null &&
|
|
227
|
+
typeof errorField === 'object' &&
|
|
228
|
+
typeof (errorField as Record<string, unknown>)['message'] === 'string'
|
|
229
|
+
) {
|
|
230
|
+
const code = (errorField as Record<string, unknown>)['code'];
|
|
231
|
+
return {
|
|
232
|
+
providerMessage: (errorField as Record<string, unknown>)['message'] as string,
|
|
233
|
+
providerCode: typeof code === 'string' || typeof code === 'number' ? code : undefined,
|
|
234
|
+
body: err['body'] ?? extractResponseData(err['response']),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 2. Parse error.body if it is a JSON string
|
|
239
|
+
const rawBody = err['body'];
|
|
240
|
+
if (typeof rawBody === 'string') {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(rawBody) as unknown;
|
|
243
|
+
if (
|
|
244
|
+
parsed !== null &&
|
|
245
|
+
typeof parsed === 'object'
|
|
246
|
+
) {
|
|
247
|
+
const parsedObj = parsed as Record<string, unknown>;
|
|
248
|
+
const parsedError = parsedObj['error'];
|
|
249
|
+
if (
|
|
250
|
+
parsedError !== null &&
|
|
251
|
+
typeof parsedError === 'object' &&
|
|
252
|
+
typeof (parsedError as Record<string, unknown>)['message'] === 'string'
|
|
253
|
+
) {
|
|
254
|
+
const code = (parsedError as Record<string, unknown>)['code'];
|
|
255
|
+
return {
|
|
256
|
+
providerMessage: (parsedError as Record<string, unknown>)['message'] as string,
|
|
257
|
+
providerCode: typeof code === 'string' || typeof code === 'number' ? code : undefined,
|
|
258
|
+
body: rawBody,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// JSON.parse failed — move on
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 3. error.body?.error?.message (body already an object)
|
|
268
|
+
if (
|
|
269
|
+
rawBody !== null &&
|
|
270
|
+
typeof rawBody === 'object'
|
|
271
|
+
) {
|
|
272
|
+
const bodyObj = rawBody as Record<string, unknown>;
|
|
273
|
+
const bodyError = bodyObj['error'];
|
|
274
|
+
if (
|
|
275
|
+
bodyError !== null &&
|
|
276
|
+
typeof bodyError === 'object' &&
|
|
277
|
+
typeof (bodyError as Record<string, unknown>)['message'] === 'string'
|
|
278
|
+
) {
|
|
279
|
+
const code = (bodyError as Record<string, unknown>)['code'];
|
|
280
|
+
return {
|
|
281
|
+
providerMessage: (bodyError as Record<string, unknown>)['message'] as string,
|
|
282
|
+
providerCode: typeof code === 'string' || typeof code === 'number' ? code : undefined,
|
|
283
|
+
body: rawBody,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 4. error.response?.data?.error?.message (axios-style)
|
|
289
|
+
const response = err['response'];
|
|
290
|
+
if (response !== null && typeof response === 'object') {
|
|
291
|
+
const data = (response as Record<string, unknown>)['data'];
|
|
292
|
+
if (data !== null && typeof data === 'object') {
|
|
293
|
+
const dataError = (data as Record<string, unknown>)['error'];
|
|
294
|
+
if (
|
|
295
|
+
dataError !== null &&
|
|
296
|
+
typeof dataError === 'object' &&
|
|
297
|
+
typeof (dataError as Record<string, unknown>)['message'] === 'string'
|
|
298
|
+
) {
|
|
299
|
+
const code = (dataError as Record<string, unknown>)['code'];
|
|
300
|
+
return {
|
|
301
|
+
providerMessage: (dataError as Record<string, unknown>)['message'] as string,
|
|
302
|
+
providerCode: typeof code === 'string' || typeof code === 'number' ? code : undefined,
|
|
303
|
+
body: data,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Error thrown when model output is truncated due to token limits
|
|
314
|
+
*/
|
|
315
|
+
export class LlmOutputTruncatedError extends LlmError {
|
|
316
|
+
public readonly contentLength: number;
|
|
317
|
+
|
|
318
|
+
constructor(context: LlmErrorContext, contentLength: number) {
|
|
319
|
+
const message =
|
|
320
|
+
`Model output was truncated (finish_reason: length). The ${contentLength}-char response exceeded the model's ` +
|
|
321
|
+
'maximum output token limit. Consider reducing input size or using a model with higher output limits.';
|
|
322
|
+
super(message, context);
|
|
323
|
+
this.name = 'LlmOutputTruncatedError';
|
|
324
|
+
this.contentLength = contentLength;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Error thrown when model returns invalid JSON.
|
|
330
|
+
*
|
|
331
|
+
* Whether to retry depends on the likely cause:
|
|
332
|
+
* - If `finish_reason` is `'stop'`, the provider claims the response completed
|
|
333
|
+
* cleanly, so the JSON is complete-but-malformed — a model/prompt problem that
|
|
334
|
+
* retrying will not fix.
|
|
335
|
+
* - If `finish_reason` is missing, `'unknown'`, or any non-`'stop'` value, the
|
|
336
|
+
* response may have been truncated mid-stream (network cut, provider hiccup).
|
|
337
|
+
* In that case, an empty/whitespace-only body or an "Unexpected end of JSON input"
|
|
338
|
+
* parse error is a strong signal of transient truncation worth retrying.
|
|
339
|
+
*
|
|
340
|
+
* Use `isLikelyTruncation` to distinguish the two cases.
|
|
341
|
+
*/
|
|
342
|
+
export class LlmJsonParseError extends LlmError {
|
|
343
|
+
public readonly rawContent: string;
|
|
344
|
+
public readonly parseError: SyntaxError;
|
|
345
|
+
|
|
346
|
+
constructor(context: LlmErrorContext, rawContent: string, parseError: SyntaxError) {
|
|
347
|
+
super(
|
|
348
|
+
`Model returned invalid JSON (${rawContent.length} chars, ` +
|
|
349
|
+
`finish_reason: ${context.metadata?.finishReason ?? 'unknown'}): ${parseError.message}`,
|
|
350
|
+
context,
|
|
351
|
+
);
|
|
352
|
+
this.name = 'LlmJsonParseError';
|
|
353
|
+
this.rawContent = rawContent;
|
|
354
|
+
this.parseError = parseError;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Returns `true` when the parse failure looks like transient truncation rather
|
|
359
|
+
* than a genuinely malformed model response.
|
|
360
|
+
*
|
|
361
|
+
* Heuristic:
|
|
362
|
+
* - If `finish_reason === 'stop'`, the provider claims the response completed
|
|
363
|
+
* normally. Retrying consistently malformed output wastes tokens and is
|
|
364
|
+
* unlikely to help, so this returns `false`.
|
|
365
|
+
* - Otherwise (finish_reason missing, `'unknown'`, or any other non-`'stop'`
|
|
366
|
+
* value), a truncated/empty response is transient (network cut, provider
|
|
367
|
+
* hiccup) and worth one retry. Returns `true` if EITHER:
|
|
368
|
+
* - the parse error message includes `'Unexpected end of JSON input'`
|
|
369
|
+
* (abrupt-end failure), OR
|
|
370
|
+
* - `rawContent.trim()` is empty (empty or whitespace-only response).
|
|
371
|
+
*/
|
|
372
|
+
get isLikelyTruncation(): boolean {
|
|
373
|
+
if (this.context.metadata?.finishReason === 'stop') {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const isAbruptEnd = this.parseError.message.includes('Unexpected end of JSON input');
|
|
378
|
+
const isEmptyContent = this.rawContent.trim().length === 0;
|
|
379
|
+
return isAbruptEnd || isEmptyContent;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Error thrown when configuration is invalid
|
|
385
|
+
*/
|
|
386
|
+
export class LlmConfigurationError extends LlmError {
|
|
387
|
+
constructor(context: LlmErrorContext, originalError?: Error) {
|
|
388
|
+
const message = 'Invalid client configuration';
|
|
389
|
+
super(message, context, originalError);
|
|
390
|
+
this.name = 'LlmConfigurationError';
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Helper function to generate request IDs
|
|
396
|
+
*/
|
|
397
|
+
export function generateRequestId(): string {
|
|
398
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Helper function to determine if an error is a timeout error
|
|
403
|
+
*/
|
|
404
|
+
export function isTimeoutError(error: unknown): boolean {
|
|
405
|
+
if (error instanceof LlmTimeoutError) {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (error instanceof Error) {
|
|
410
|
+
const message = error.message.toLowerCase();
|
|
411
|
+
const name = error.name?.toLowerCase() || '';
|
|
412
|
+
return (
|
|
413
|
+
message.includes('timeout') ||
|
|
414
|
+
message.includes('timed out') ||
|
|
415
|
+
name.includes('timeout') ||
|
|
416
|
+
name === 'apiconnectiontimeouterror'
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Helper function to wrap SDK errors with enhanced context.
|
|
425
|
+
* Extracts additional information from SDK error objects (status codes, error types,
|
|
426
|
+
* request IDs) and creates the appropriate LlmError subclass.
|
|
427
|
+
*/
|
|
428
|
+
export function wrapSdkError(
|
|
429
|
+
error: unknown,
|
|
430
|
+
context: LlmErrorContext,
|
|
431
|
+
): LlmError {
|
|
432
|
+
// If already wrapped, return as-is
|
|
433
|
+
if (error instanceof LlmError) {
|
|
434
|
+
return error;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const originalError = error instanceof Error ? error : new Error(String(error));
|
|
438
|
+
|
|
439
|
+
// Extract additional context from SDK errors (OpenAI, Anthropic SDKs)
|
|
440
|
+
const errorCode = (error as any)?.code as string | undefined;
|
|
441
|
+
const errorType = (error as any)?.type || (error as any)?.error?.type;
|
|
442
|
+
const providerRequestId =
|
|
443
|
+
(error as any)?.headers?.['x-request-id'] ||
|
|
444
|
+
(error as any)?.headers?.get?.('x-request-id');
|
|
445
|
+
|
|
446
|
+
// Enrich metadata with SDK error details
|
|
447
|
+
if (errorCode || errorType || providerRequestId) {
|
|
448
|
+
context.metadata = {
|
|
449
|
+
...context.metadata,
|
|
450
|
+
...(errorCode && { errorCode }),
|
|
451
|
+
...(errorType && { errorType }),
|
|
452
|
+
...(providerRequestId && { providerRequestId }),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Detect timeout errors
|
|
457
|
+
if (isTimeoutError(error)) {
|
|
458
|
+
return new LlmTimeoutError(context, originalError);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Detect API errors (check for status codes)
|
|
462
|
+
const statusCode = (error as any)?.status || (error as any)?.statusCode;
|
|
463
|
+
if (statusCode) {
|
|
464
|
+
return new LlmApiError(context, statusCode, originalError, extractProviderError(error));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Detect validation errors
|
|
468
|
+
if (originalError.name === 'ZodError' || originalError.message.includes('validation')) {
|
|
469
|
+
return new LlmValidationError(context, originalError);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Default to generic LlmError
|
|
473
|
+
return new LlmError(originalError.message, context, originalError);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Determines whether an error is retryable (transient) or permanent.
|
|
478
|
+
*
|
|
479
|
+
* Retryable: timeouts, 5xx server errors, 429 rate limits, generic/unknown errors,
|
|
480
|
+
* and JSON parse errors that look like transient truncation (finish_reason missing/
|
|
481
|
+
* unknown and content is empty or fails with "Unexpected end of JSON input").
|
|
482
|
+
*
|
|
483
|
+
* Non-retryable: validation errors, config errors, output truncation, 4xx client
|
|
484
|
+
* errors, and JSON parse errors where finish_reason is 'stop' (complete-but-malformed
|
|
485
|
+
* output is a model/prompt problem that retrying will not fix).
|
|
486
|
+
*/
|
|
487
|
+
export function isRetryableError(error: unknown): boolean {
|
|
488
|
+
// Non-retryable error types — these won't resolve on retry
|
|
489
|
+
if (error instanceof LlmConfigurationError) return false;
|
|
490
|
+
if (error instanceof LlmValidationError) return false;
|
|
491
|
+
if (error instanceof LlmOutputTruncatedError) return false;
|
|
492
|
+
if (error instanceof LlmJsonParseError) return error.isLikelyTruncation;
|
|
493
|
+
|
|
494
|
+
// API errors: retry on 5xx and 429, not on other 4xx
|
|
495
|
+
if (error instanceof LlmApiError) {
|
|
496
|
+
if (!error.statusCode) return true;
|
|
497
|
+
if (error.statusCode === 429) return true;
|
|
498
|
+
if (error.statusCode >= 500) return true;
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Timeouts are retryable
|
|
503
|
+
if (error instanceof LlmTimeoutError) return true;
|
|
504
|
+
|
|
505
|
+
// Generic LlmError (e.g. transient "An error occurred while processing the request")
|
|
506
|
+
// and unknown errors — assume retryable
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursively walks parsed JSON alongside a Zod v4 schema and replaces
|
|
5
|
+
* the literal string `"null"` with actual `null` wherever the schema
|
|
6
|
+
* declares `.nullable()`.
|
|
7
|
+
*
|
|
8
|
+
* Gemini models return the literal string `"null"` instead of JSON `null`
|
|
9
|
+
* for nullable fields in structured output. This function normalises
|
|
10
|
+
* the parsed data **in-place** before Zod validation so that `.nullable()`
|
|
11
|
+
* fields validate correctly.
|
|
12
|
+
*
|
|
13
|
+
* Because we access Zod v4 internals (`_zod.def`), `any` casts are
|
|
14
|
+
* unavoidable for the schema introspection paths.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeNullStrings(data: unknown, schema: z.ZodType): unknown {
|
|
17
|
+
const def = (schema as any)._zod.def;
|
|
18
|
+
const type: string = def.type;
|
|
19
|
+
|
|
20
|
+
switch (type) {
|
|
21
|
+
case 'nullable': {
|
|
22
|
+
if (data === 'null') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return normalizeNullStrings(data, def.innerType);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
case 'optional':
|
|
29
|
+
case 'default': {
|
|
30
|
+
return normalizeNullStrings(data, def.innerType);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
case 'object': {
|
|
34
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
const shape: Record<string, z.ZodType> = def.shape;
|
|
38
|
+
const record = data as Record<string, unknown>;
|
|
39
|
+
for (const key of Object.keys(shape)) {
|
|
40
|
+
if (key in record) {
|
|
41
|
+
record[key] = normalizeNullStrings(record[key], shape[key]!);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case 'array': {
|
|
48
|
+
if (!Array.isArray(data)) {
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
const element: z.ZodType = def.element;
|
|
52
|
+
for (let i = 0; i < data.length; i++) {
|
|
53
|
+
data[i] = normalizeNullStrings(data[i], element);
|
|
54
|
+
}
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case 'union': {
|
|
59
|
+
const options: readonly z.ZodType[] = def.options;
|
|
60
|
+
// Find a nullable (or nullable-containing) option and recurse with it.
|
|
61
|
+
for (const option of options) {
|
|
62
|
+
const optDef = (option as any)._zod.def;
|
|
63
|
+
if (optDef.type === 'nullable') {
|
|
64
|
+
return normalizeNullStrings(data, option);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// No nullable option found — try each option for structural recursion
|
|
68
|
+
for (const option of options) {
|
|
69
|
+
const optDef = (option as any)._zod.def;
|
|
70
|
+
if (optDef.type === 'object' || optDef.type === 'array') {
|
|
71
|
+
return normalizeNullStrings(data, option);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'tuple': {
|
|
78
|
+
if (!Array.isArray(data)) {
|
|
79
|
+
return data;
|
|
80
|
+
}
|
|
81
|
+
const items: readonly z.ZodType[] = def.items;
|
|
82
|
+
for (let i = 0; i < items.length && i < data.length; i++) {
|
|
83
|
+
data[i] = normalizeNullStrings(data[i], items[i]!);
|
|
84
|
+
}
|
|
85
|
+
return data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'record': {
|
|
89
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
const valueType: z.ZodType = def.valueType;
|
|
93
|
+
const record = data as Record<string, unknown>;
|
|
94
|
+
for (const key of Object.keys(record)) {
|
|
95
|
+
record[key] = normalizeNullStrings(record[key], valueType);
|
|
96
|
+
}
|
|
97
|
+
return data;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
default:
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Resolves JSON Schema $ref/$defs by inlining all references and converts
|
|
2
|
+
// anyOf nullable patterns ({ anyOf: [T, { type: "null" }] }) for Gemini compatibility.
|
|
3
|
+
|
|
4
|
+
const MAX_INLINE_DEPTH = 50;
|
|
5
|
+
|
|
6
|
+
export function resolveRefs(
|
|
7
|
+
schema: Record<string, unknown>,
|
|
8
|
+
): Record<string, unknown> {
|
|
9
|
+
const defs = ((schema.$defs ?? schema.definitions ?? {}) as Record<
|
|
10
|
+
string,
|
|
11
|
+
unknown
|
|
12
|
+
>);
|
|
13
|
+
|
|
14
|
+
function inline(node: unknown, depth: number): unknown {
|
|
15
|
+
if (depth > MAX_INLINE_DEPTH) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`resolveRefs: exceeded max inline depth of ${MAX_INLINE_DEPTH} — possible circular $ref`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (Array.isArray(node)) {
|
|
22
|
+
return node.map((item) => inline(item, depth + 1));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (node === null || typeof node !== "object") {
|
|
26
|
+
return node;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const obj = node as Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
// Resolve $ref
|
|
32
|
+
if (typeof obj.$ref === "string") {
|
|
33
|
+
const refPath = obj.$ref;
|
|
34
|
+
const match = refPath.match(/^#\/(\$defs|definitions)\/(.+)$/);
|
|
35
|
+
if (!match) {
|
|
36
|
+
return obj;
|
|
37
|
+
}
|
|
38
|
+
const defName = match[2]!;
|
|
39
|
+
const definition = defs[defName];
|
|
40
|
+
if (definition === undefined) {
|
|
41
|
+
throw new Error(`resolveRefs: missing definition for "${defName}"`);
|
|
42
|
+
}
|
|
43
|
+
return inline(structuredClone(definition), depth + 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Convert anyOf nullable pattern: { anyOf: [T, { type: "null" }] }
|
|
47
|
+
if (Array.isArray(obj.anyOf) && obj.anyOf.length === 2) {
|
|
48
|
+
const [first, second] = obj.anyOf as [
|
|
49
|
+
Record<string, unknown>,
|
|
50
|
+
Record<string, unknown>,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
let realType: Record<string, unknown> | null = null;
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
second !== null &&
|
|
57
|
+
typeof second === "object" &&
|
|
58
|
+
second.type === "null"
|
|
59
|
+
) {
|
|
60
|
+
realType = first;
|
|
61
|
+
} else if (
|
|
62
|
+
first !== null &&
|
|
63
|
+
typeof first === "object" &&
|
|
64
|
+
first.type === "null"
|
|
65
|
+
) {
|
|
66
|
+
realType = second;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (realType !== null) {
|
|
70
|
+
const { anyOf: _, ...rest } = obj;
|
|
71
|
+
const resolved = inline(realType, depth + 1) as Record<
|
|
72
|
+
string,
|
|
73
|
+
unknown
|
|
74
|
+
>;
|
|
75
|
+
return { ...rest, ...resolved, nullable: true };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Recurse into all properties
|
|
80
|
+
const result: Record<string, unknown> = {};
|
|
81
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
82
|
+
result[key] = inline(value, depth + 1);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const resolved = inline(schema, 0) as Record<string, unknown>;
|
|
88
|
+
delete resolved.$defs;
|
|
89
|
+
delete resolved.definitions;
|
|
90
|
+
return resolved;
|
|
91
|
+
}
|