adaptive-memory-multi-model-router 2.7.0 → 2.9.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,578 @@
1
+ /**
2
+ * A3M Router - Per-Provider Retry Logic
3
+ *
4
+ * Implements exponential backoff with jitter for transient errors,
5
+ * rate limit (429) handling with Retry-After header support,
6
+ * and context window validation before sending requests.
7
+ */
8
+
9
+ import { countTokens, estimateTokens } from '../utils/tokenUtils';
10
+
11
+ // ============================================================
12
+ // TYPES
13
+ // ============================================================
14
+
15
+ export interface RetryConfig {
16
+ maxRetries: number;
17
+ initialDelayMs: number;
18
+ maxDelayMs: number;
19
+ backoffMultiplier: number;
20
+ retryableErrors?: string[]; // error codes that should trigger retry
21
+ }
22
+
23
+ export interface ProviderRetryConfig {
24
+ [providerName: string]: {
25
+ timeout: number; // ms
26
+ retry: RetryConfig;
27
+ rateLimitRetries?: number; // max retries on 429
28
+ };
29
+ }
30
+
31
+ export interface RetryStats {
32
+ totalRequests: number;
33
+ successfulRequests: number;
34
+ failedRequests: number;
35
+ totalRetries: number;
36
+ rateLimitRetries: number;
37
+ averageLatencyMs: number;
38
+ }
39
+
40
+ export interface ContextWindowValidation {
41
+ valid: boolean;
42
+ reason?: string;
43
+ suggestedProvider?: string;
44
+ }
45
+
46
+ // ============================================================
47
+ // DEFAULT CONFIGURATION
48
+ // ============================================================
49
+
50
+ const DEFAULT_RETRY_CONFIG: RetryConfig = {
51
+ maxRetries: 3,
52
+ initialDelayMs: 1000,
53
+ maxDelayMs: 30000,
54
+ backoffMultiplier: 2,
55
+ retryableErrors: [
56
+ 'ECONNRESET',
57
+ 'ETIMEDOUT',
58
+ 'ECONNREFUSED',
59
+ '503',
60
+ '429',
61
+ '500',
62
+ '502',
63
+ '504',
64
+ 'NETWORK_ERROR',
65
+ 'TIMEOUT',
66
+ 'SOCKET_HANG_UP',
67
+ 'EAI_AGAIN',
68
+ ],
69
+ };
70
+
71
+ export const DEFAULT_PROVIDER_CONFIG: ProviderRetryConfig = {
72
+ // Chinese providers need longer timeouts and more retries
73
+ 'deepseek': {
74
+ timeout: 30000,
75
+ retry: {
76
+ ...DEFAULT_RETRY_CONFIG,
77
+ maxRetries: 5,
78
+ initialDelayMs: 2000,
79
+ },
80
+ rateLimitRetries: 3,
81
+ },
82
+ 'zhipu': {
83
+ timeout: 25000,
84
+ retry: {
85
+ ...DEFAULT_RETRY_CONFIG,
86
+ maxRetries: 4,
87
+ initialDelayMs: 1500,
88
+ },
89
+ rateLimitRetries: 3,
90
+ },
91
+ 'qwen': {
92
+ timeout: 25000,
93
+ retry: {
94
+ ...DEFAULT_RETRY_CONFIG,
95
+ maxRetries: 4,
96
+ },
97
+ rateLimitRetries: 3,
98
+ },
99
+ 'moonshot': {
100
+ timeout: 25000,
101
+ retry: {
102
+ ...DEFAULT_RETRY_CONFIG,
103
+ maxRetries: 4,
104
+ },
105
+ rateLimitRetries: 3,
106
+ },
107
+ 'minimax': {
108
+ timeout: 20000,
109
+ retry: {
110
+ ...DEFAULT_RETRY_CONFIG,
111
+ maxRetries: 3,
112
+ },
113
+ rateLimitRetries: 2,
114
+ },
115
+ 'yi': {
116
+ timeout: 20000,
117
+ retry: {
118
+ ...DEFAULT_RETRY_CONFIG,
119
+ maxRetries: 3,
120
+ },
121
+ rateLimitRetries: 2,
122
+ },
123
+ // US providers are faster
124
+ 'openai': {
125
+ timeout: 15000,
126
+ retry: {
127
+ ...DEFAULT_RETRY_CONFIG,
128
+ maxRetries: 3,
129
+ },
130
+ rateLimitRetries: 2,
131
+ },
132
+ 'anthropic': {
133
+ timeout: 15000,
134
+ retry: {
135
+ ...DEFAULT_RETRY_CONFIG,
136
+ maxRetries: 3,
137
+ },
138
+ rateLimitRetries: 2,
139
+ },
140
+ 'groq': {
141
+ timeout: 10000,
142
+ retry: {
143
+ ...DEFAULT_RETRY_CONFIG,
144
+ maxRetries: 2,
145
+ },
146
+ rateLimitRetries: 1,
147
+ },
148
+ 'cerebras': {
149
+ timeout: 10000,
150
+ retry: {
151
+ ...DEFAULT_RETRY_CONFIG,
152
+ maxRetries: 2,
153
+ },
154
+ rateLimitRetries: 1,
155
+ },
156
+ // Default fallback for unknown providers
157
+ 'default': {
158
+ timeout: 15000,
159
+ retry: { ...DEFAULT_RETRY_CONFIG },
160
+ rateLimitRetries: 2,
161
+ },
162
+ };
163
+
164
+ // Provider context window limits (approximate)
165
+ const PROVIDER_CONTEXT_LIMITS: Record<string, number> = {
166
+ 'openai': 128000,
167
+ 'anthropic': 200000,
168
+ 'deepseek': 64000,
169
+ 'qwen': 32000,
170
+ 'zhipu': 128000,
171
+ 'moonshot': 128000,
172
+ 'minimax': 1000000,
173
+ 'yi': 16000,
174
+ 'groq': 32000,
175
+ 'cerebras': 8000,
176
+ 'default': 8192,
177
+ };
178
+
179
+ // ============================================================
180
+ // PROVIDER RETRY HANDLER
181
+ // ============================================================
182
+
183
+ export class ProviderRetryHandler {
184
+ private configs: Map<string, { timeout: number; retry: RetryConfig; rateLimitRetries: number }>;
185
+ private stats: Map<string, RetryStats>;
186
+ private customProviders: Set<string>;
187
+
188
+ constructor(customConfigs?: ProviderRetryConfig) {
189
+ this.configs = new Map();
190
+ this.stats = new Map();
191
+ this.customProviders = new Set();
192
+
193
+ // Initialize with defaults
194
+ for (const [provider, config] of Object.entries(DEFAULT_PROVIDER_CONFIG)) {
195
+ this.configs.set(provider, {
196
+ timeout: config.timeout,
197
+ retry: { ...config.retry },
198
+ rateLimitRetries: config.rateLimitRetries ?? 2,
199
+ });
200
+ this.initStats(provider);
201
+ }
202
+
203
+ // Apply custom configs
204
+ if (customConfigs) {
205
+ for (const [provider, config] of Object.entries(customConfigs)) {
206
+ this.configureProvider(provider, config);
207
+ }
208
+ }
209
+ }
210
+
211
+ private initStats(provider: string): void {
212
+ this.stats.set(provider, {
213
+ totalRequests: 0,
214
+ successfulRequests: 0,
215
+ failedRequests: 0,
216
+ totalRetries: 0,
217
+ rateLimitRetries: 0,
218
+ averageLatencyMs: 0,
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Configure or update a provider's retry settings
224
+ */
225
+ configureProvider(
226
+ provider: string,
227
+ config: Partial<{
228
+ timeout: number;
229
+ retry: Partial<RetryConfig>;
230
+ rateLimitRetries: number;
231
+ }>
232
+ ): void {
233
+ const existing = this.configs.get(provider) || {
234
+ timeout: 15000,
235
+ retry: { ...DEFAULT_RETRY_CONFIG },
236
+ rateLimitRetries: 2,
237
+ };
238
+
239
+ this.configs.set(provider, {
240
+ timeout: config.timeout ?? existing.timeout,
241
+ retry: {
242
+ ...existing.retry,
243
+ ...config.retry,
244
+ retryableErrors: config.retry?.retryableErrors ?? existing.retry.retryableErrors,
245
+ },
246
+ rateLimitRetries: config.rateLimitRetries ?? existing.rateLimitRetries,
247
+ });
248
+
249
+ if (!this.stats.has(provider)) {
250
+ this.initStats(provider);
251
+ }
252
+
253
+ this.customProviders.add(provider);
254
+ }
255
+
256
+ /**
257
+ * Get current config for a provider
258
+ */
259
+ getConfig(provider: string): { timeout: number; retry: RetryConfig; rateLimitRetries: number } {
260
+ return (
261
+ this.configs.get(provider) ||
262
+ this.configs.get('default')!
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Execute a function with retry logic
268
+ */
269
+ async executeWithRetry<T>(
270
+ provider: string,
271
+ fn: () => Promise<T>,
272
+ options?: { timeout?: number; onRetry?: (attempt: number, error: any, delayMs: number) => void }
273
+ ): Promise<T> {
274
+ const config = this.getConfig(provider);
275
+ const timeout = options?.timeout ?? config.timeout;
276
+
277
+ let lastError: any;
278
+ let attempts = 0;
279
+
280
+ for (let retryAttempt = 0; retryAttempt <= config.retry.maxRetries; retryAttempt++) {
281
+ attempts++;
282
+
283
+ try {
284
+ const result = await this.executeWithTimeout(fn, timeout);
285
+
286
+ // Success - update stats
287
+ this.recordSuccess(provider, 0);
288
+
289
+ return result;
290
+ } catch (error: any) {
291
+ lastError = error;
292
+
293
+ // Check if we've exhausted retries
294
+ if (retryAttempt >= config.retry.maxRetries) {
295
+ break;
296
+ }
297
+
298
+ // Check if error is retryable
299
+ if (!this.isRetryableError(error)) {
300
+ this.recordFailure(provider, attempts - 1);
301
+ throw error;
302
+ }
303
+
304
+ // Handle rate limiting (429)
305
+ const isRateLimit = this.isRateLimitError(error);
306
+ const rateLimitRetries = isRateLimit ? config.rateLimitRetries : 0;
307
+
308
+ if (isRateLimit && retryAttempt >= rateLimitRetries) {
309
+ // Exceeded rate limit retries
310
+ this.recordFailure(provider, attempts - 1);
311
+ throw error;
312
+ }
313
+
314
+ // Calculate delay
315
+ const delayMs = this.calculateBackoffDelay(retryAttempt, config.retry, error);
316
+
317
+ // Notify callback if provided
318
+ if (options?.onRetry) {
319
+ options.onRetry(retryAttempt + 1, error, delayMs);
320
+ }
321
+
322
+ // Wait before retry
323
+ await this.sleep(delayMs);
324
+
325
+ this.recordRetry(provider, isRateLimit);
326
+ }
327
+ }
328
+
329
+ this.recordFailure(provider, attempts - 1);
330
+ throw lastError;
331
+ }
332
+
333
+ /**
334
+ * Execute with custom timeout wrapper
335
+ */
336
+ private async executeWithTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T> {
337
+ return new Promise((resolve, reject) => {
338
+ const timer = setTimeout(() => {
339
+ reject(this.createTimeoutError(timeoutMs));
340
+ }, timeoutMs);
341
+
342
+ fn()
343
+ .then((result) => {
344
+ clearTimeout(timer);
345
+ resolve(result);
346
+ })
347
+ .catch((error) => {
348
+ clearTimeout(timer);
349
+ reject(error);
350
+ });
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Check if an error should trigger a retry
356
+ */
357
+ isRetryableError(error: any): boolean {
358
+ if (!error) return false;
359
+
360
+ const config = this.configs.get('default')!.retry;
361
+ const retryableErrors = config.retryableErrors || DEFAULT_RETRY_CONFIG.retryableErrors!;
362
+
363
+ // Check error code/message
364
+ const errorCode = error.code || error.status || error.statusCode || '';
365
+ const errorMessage = error.message || '';
366
+ const errorString = String(errorCode).toUpperCase();
367
+
368
+ for (const retryable of retryableErrors) {
369
+ if (
370
+ errorString.includes(retryable) ||
371
+ errorMessage.includes(retryable)
372
+ ) {
373
+ return true;
374
+ }
375
+ }
376
+
377
+ // Check for specific error patterns
378
+ if (error.status === 429) return true;
379
+ if (error.status >= 500 && error.status < 600) return true;
380
+ if (error.errno === 'ETIMEDOUT' || error.errno === 'ECONNRESET') return true;
381
+
382
+ return false;
383
+ }
384
+
385
+ /**
386
+ * Check if error is a rate limit (429)
387
+ */
388
+ isRateLimitError(error: any): boolean {
389
+ return error?.status === 429 || error?.statusCode === 429;
390
+ }
391
+
392
+ /**
393
+ * Calculate backoff delay with exponential increase and jitter
394
+ */
395
+ calculateBackoffDelay(
396
+ attempt: number,
397
+ config: RetryConfig,
398
+ error?: any
399
+ ): number {
400
+ // Check for Retry-After header on 429
401
+ if (error && this.isRateLimitError(error)) {
402
+ const retryAfter = error.headers?.['retry-after'] || error.headers?.['Retry-After'];
403
+ if (retryAfter) {
404
+ const retryAfterMs = parseInt(String(retryAfter), 10) * 1000;
405
+ if (!isNaN(retryAfterMs) && retryAfterMs > 0) {
406
+ return Math.min(retryAfterMs, config.maxDelayMs);
407
+ }
408
+ // Could also be a date string - handle that case too
409
+ const retryAfterDate = new Date(retryAfter);
410
+ if (!isNaN(retryAfterDate.getTime())) {
411
+ const diffMs = retryAfterDate.getTime() - Date.now();
412
+ if (diffMs > 0) {
413
+ return Math.min(diffMs, config.maxDelayMs);
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ // Exponential backoff: initialDelay * (multiplier ^ attempt)
420
+ const baseDelay = config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt);
421
+
422
+ // Cap at max delay
423
+ const cappedDelay = Math.min(baseDelay, config.maxDelayMs);
424
+
425
+ // Add jitter: delay *= (0.5 + random * 0.5) = delay * [0.5, 1.0]
426
+ const jitter = 0.5 + Math.random() * 0.5;
427
+
428
+ return Math.floor(cappedDelay * jitter);
429
+ }
430
+
431
+ /**
432
+ * Validate context window size before sending request
433
+ */
434
+ validateContextWindow(
435
+ provider: string,
436
+ prompt: string,
437
+ expectedTokens?: number
438
+ ): ContextWindowValidation {
439
+ // Use actual token count or estimate
440
+ const actualTokens = expectedTokens || estimateTokens(prompt);
441
+
442
+ // Get provider's context limit
443
+ const contextLimit = PROVIDER_CONTEXT_LIMITS[provider] || PROVIDER_CONTEXT_LIMITS['default'];
444
+
445
+ // Estimate output tokens (rough guess: 25% of input for responses)
446
+ const estimatedOutputTokens = Math.floor(actualTokens * 0.25);
447
+ const totalTokens = actualTokens + estimatedOutputTokens;
448
+
449
+ if (totalTokens > contextLimit) {
450
+ // Find alternative providers with larger context
451
+ const largerProviders = Object.entries(PROVIDER_CONTEXT_LIMITS)
452
+ .filter(([name, limit]) => name !== provider && limit > contextLimit)
453
+ .sort(([, a], [, b]) => b - a);
454
+
455
+ const suggested = largerProviders.length > 0 ? largerProviders[0][0] : undefined;
456
+
457
+ return {
458
+ valid: false,
459
+ reason: `Context window exceeded: ${totalTokens} tokens (estimated) > ${contextLimit} limit for ${provider}`,
460
+ suggestedProvider: suggested,
461
+ };
462
+ }
463
+
464
+ return { valid: true };
465
+ }
466
+
467
+ /**
468
+ * Get retry statistics for a provider
469
+ */
470
+ getStats(provider: string): RetryStats {
471
+ return this.stats.get(provider) || this.initStats(provider) as unknown as RetryStats;
472
+ }
473
+
474
+ /**
475
+ * Get all provider stats
476
+ */
477
+ getAllStats(): Record<string, RetryStats> {
478
+ const result: Record<string, RetryStats> = {};
479
+ for (const [provider, stats] of this.stats.entries()) {
480
+ result[provider] = stats;
481
+ }
482
+ return result;
483
+ }
484
+
485
+ /**
486
+ * Reset stats for a provider
487
+ */
488
+ resetStats(provider?: string): void {
489
+ if (provider) {
490
+ this.initStats(provider);
491
+ } else {
492
+ for (const provider of this.configs.keys()) {
493
+ this.initStats(provider);
494
+ }
495
+ }
496
+ }
497
+
498
+ // ============================================================
499
+ // PRIVATE HELPERS
500
+ // ============================================================
501
+
502
+ private createTimeoutError(timeoutMs: number): any {
503
+ const error = new Error(`Request timed out after ${timeoutMs}ms`);
504
+ error.code = 'ETIMEDOUT';
505
+ error.status = 408;
506
+ error.statusCode = 408;
507
+ return error;
508
+ }
509
+
510
+ private sleep(ms: number): Promise<void> {
511
+ return new Promise((resolve) => setTimeout(resolve, ms));
512
+ }
513
+
514
+ private recordSuccess(provider: string, latencyMs: number): void {
515
+ const stats = this.stats.get(provider);
516
+ if (!stats) return;
517
+
518
+ stats.totalRequests++;
519
+ stats.successfulRequests++;
520
+
521
+ // Running average for latency
522
+ if (latencyMs > 0) {
523
+ const totalLatency = stats.averageLatencyMs * (stats.totalRequests - 1) + latencyMs;
524
+ stats.averageLatencyMs = totalLatency / stats.totalRequests;
525
+ }
526
+ }
527
+
528
+ private recordFailure(provider: string, retryCount: number): void {
529
+ const stats = this.stats.get(provider);
530
+ if (!stats) return;
531
+
532
+ stats.totalRequests++;
533
+ stats.failedRequests++;
534
+ stats.totalRetries += retryCount;
535
+ }
536
+
537
+ private recordRetry(provider: string, isRateLimit: boolean): void {
538
+ const stats = this.stats.get(provider);
539
+ if (!stats) return;
540
+
541
+ stats.totalRetries++;
542
+ if (isRateLimit) {
543
+ stats.rateLimitRetries++;
544
+ }
545
+ }
546
+ }
547
+
548
+ // ============================================================
549
+ // CONVENIENCE FUNCTIONS
550
+ // ============================================================
551
+
552
+ /**
553
+ * Create a retry handler with optional custom configs
554
+ */
555
+ export function createRetryHandler(customConfigs?: ProviderRetryConfig): ProviderRetryHandler {
556
+ return new ProviderRetryHandler(customConfigs);
557
+ }
558
+
559
+ /**
560
+ * Default retry handler instance (singleton)
561
+ */
562
+ let defaultHandler: ProviderRetryHandler | null = null;
563
+
564
+ export function getDefaultRetryHandler(): ProviderRetryHandler {
565
+ if (!defaultHandler) {
566
+ defaultHandler = new ProviderRetryHandler();
567
+ }
568
+ return defaultHandler;
569
+ }
570
+
571
+ // ============================================================
572
+ // EXPORTS
573
+ // ============================================================
574
+
575
+ export {
576
+ DEFAULT_RETRY_CONFIG,
577
+ PROVIDER_CONTEXT_LIMITS,
578
+ };