@splashcodex/api-key-manager 5.0.0 → 5.2.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/src/index.ts CHANGED
@@ -1,1005 +1,1071 @@
1
- /**
2
- * Universal ApiKeyManager v5.0 — Ecosystem Edition
3
- * Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff, Strategies,
4
- * Event Emitter, Fallback, execute(), Timeout, Auto-Retry, Provider Tags,
5
- * Health Checks, Bulkhead/Concurrency
6
- * NEW in v5.0: Provider Presets (GeminiManager, OpenAIManager, MultiManager),
7
- * Built-in Persistence (FileStorage, MemoryStorage)
8
- * Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
9
- */
10
-
11
- import { EventEmitter } from 'events';
12
-
13
- // ─── Re-exports: Persistence ─────────────────────────────────────────────────
14
- // Persistence adapters can be imported from root or via subpath
15
- export { FileStorage } from './persistence/file';
16
- export type { FileStorageOptions } from './persistence/file';
17
- export { MemoryStorage } from './persistence/memory';
18
-
19
- // ─── Presets ─────────────────────────────────────────────────────────────────
20
- // Presets are available via subpath imports to avoid circular dependencies:
21
- // import { GeminiManager } from '@splashcodex/api-key-manager/presets/gemini';
22
- // import { OpenAIManager } from '@splashcodex/api-key-manager/presets/openai';
23
- // import { MultiManager } from '@splashcodex/api-key-manager/presets/multi';
24
- // Or import all at once:
25
- // import { GeminiManager, OpenAIManager, MultiManager } from '@splashcodex/api-key-manager/presets';
26
-
27
-
28
-
29
- // ─── Interfaces & Types ──────────────────────────────────────────────────────
30
-
31
- export interface KeyState {
32
- key: string;
33
- failCount: number; // Consecutive failures
34
- failedAt: number | null; // Timestamp of last failure
35
- isQuotaError: boolean; // Was last error a 429?
36
- circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN' | 'DEAD';
37
- lastUsed: number;
38
- successCount: number;
39
- totalRequests: number;
40
- halfOpenTestTime: number | null;
41
- customCooldown: number | null; // From Retry-After header
42
- // v2.0 Stats
43
- weight: number; // 0.0 - 1.0 (Default 1.0)
44
- averageLatency: number; // Rolling average latency in ms
45
- totalLatency: number; // Sum of all latency checks (for calculating average)
46
- latencySamples: number; // Number of samples
47
- // v3.0 Fields
48
- provider: string; // Provider tag (e.g. 'openai', 'gemini')
49
- }
50
-
51
- export type ErrorType =
52
- | 'QUOTA' // 429 - Rotate key, respect cooldown
53
- | 'TRANSIENT' // 500/503/504 - Retry with backoff
54
- | 'AUTH' // 403 - Key is dead, remove from pool
55
- | 'BAD_REQUEST' // 400 - Do not retry, fix request
56
- | 'SAFETY' // finishReason: SAFETY - Not a key issue
57
- | 'RECITATION' // finishReason: RECITATION - Not a key issue
58
- | 'TIMEOUT' // Request timed out
59
- | 'UNKNOWN'; // Catch-all
60
-
61
- export interface ErrorClassification {
62
- type: ErrorType;
63
- retryable: boolean;
64
- cooldownMs: number;
65
- markKeyFailed: boolean;
66
- markKeyDead: boolean;
67
- }
68
-
69
- export interface ApiKeyManagerStats {
70
- total: number;
71
- healthy: number;
72
- cooling: number;
73
- dead: number;
74
- }
75
-
76
- export interface ExecuteOptions {
77
- timeoutMs?: number; // Timeout per attempt in ms
78
- maxRetries?: number; // Max retry attempts (default: 0 = no retry)
79
- finishReason?: string; // For Gemini finishReason handling
80
- provider?: string; // Filter keys by provider (e.g. 'openai')
81
- }
82
-
83
- export interface ApiKeyManagerOptions {
84
- storage?: any;
85
- strategy?: LoadBalancingStrategy;
86
- fallbackFn?: () => any;
87
- concurrency?: number; // Max concurrent execute() calls
88
- semanticCache?: {
89
- threshold?: number; // Similarity threshold (0.0 - 1.0, default 0.95)
90
- ttlMs?: number; // Cache TTL
91
- getEmbedding: (text: string) => Promise<number[]>;
92
- };
93
- }
94
-
95
- export interface CacheEntry {
96
- vector: number[];
97
- prompt: string;
98
- response: any;
99
- timestamp: number;
100
- }
101
-
102
- // ─── Event Types ─────────────────────────────────────────────────────────────
103
-
104
- export interface ApiKeyManagerEventMap {
105
- keyDead: (key: string) => void;
106
- circuitOpen: (key: string) => void;
107
- circuitHalfOpen: (key: string) => void;
108
- keyRecovered: (key: string) => void;
109
- fallback: (reason: string) => void;
110
- allKeysExhausted: () => void;
111
- retry: (key: string, attempt: number, delayMs: number) => void;
112
- healthCheckFailed: (key: string, error: any) => void;
113
- healthCheckPassed: (key: string) => void;
114
- executeSuccess: (key: string, durationMs: number) => void;
115
- executeFailed: (key: string, error: any) => void;
116
- bulkheadRejected: () => void;
117
- }
118
-
119
- // ─── Config ──────────────────────────────────────────────────────────────────
120
-
121
- const CONFIG = {
122
- MAX_CONSECUTIVE_FAILURES: 5,
123
- COOLDOWN_TRANSIENT: 60 * 1000, // 1 minute
124
- COOLDOWN_QUOTA: 5 * 60 * 1000, // 5 minutes (default if no Retry-After)
125
- COOLDOWN_QUOTA_DAILY: 60 * 60 * 1000, // 1 hour for RPD exhaustion
126
- HALF_OPEN_TEST_DELAY: 60 * 1000, // 1 minute after open
127
- MAX_BACKOFF: 64 * 1000, // 64 seconds max
128
- BASE_BACKOFF: 1000, // 1 second base
129
- };
130
-
131
- // Error classification patterns
132
- const ERROR_PATTERNS = {
133
- isQuotaError: /429|quota|exhausted|resource.?exhausted|too.?many.?requests|rate.?limit/i,
134
- isAuthError: /403|permission.?denied|invalid.?api.?key|unauthorized|unauthenticated/i,
135
- isSafetyBlock: /safety|blocked|recitation|harmful/i,
136
- isTransient: /500|502|503|504|internal|unavailable|deadline|timeout|overloaded/i,
137
- isBadRequest: /400|invalid.?argument|failed.?precondition|malformed|not.?found|404/i,
138
- };
139
-
140
- // ─── Custom Errors ───────────────────────────────────────────────────────────
141
-
142
- export class TimeoutError extends Error {
143
- constructor(ms: number) {
144
- super(`Request timed out after ${ms}ms`);
145
- this.name = 'TimeoutError';
146
- }
147
- }
148
-
149
- export class BulkheadRejectionError extends Error {
150
- constructor() {
151
- super('Bulkhead capacity exceeded — too many concurrent requests');
152
- this.name = 'BulkheadRejectionError';
153
- }
154
- }
155
-
156
- export class AllKeysExhaustedError extends Error {
157
- constructor() {
158
- super('All API keys exhausted — no healthy keys available');
159
- this.name = 'AllKeysExhaustedError';
160
- }
161
- }
162
-
163
- // ─── Strategies ──────────────────────────────────────────────────────────────
164
-
165
- /**
166
- * Strategy Interface for selecting the next key
167
- */
168
- export interface LoadBalancingStrategy {
169
- next(candidates: KeyState[]): KeyState | null;
170
- }
171
-
172
- /**
173
- * Standard Strategy: Least Failed > Least Recently Used
174
- */
175
- export class StandardStrategy implements LoadBalancingStrategy {
176
- next(candidates: KeyState[]): KeyState | null {
177
- candidates.sort((a, b) => {
178
- if (a.failCount !== b.failCount) return a.failCount - b.failCount;
179
- return a.lastUsed - b.lastUsed;
180
- });
181
- return candidates[0] || null;
182
- }
183
- }
184
-
185
- /**
186
- * Weighted Strategy: Probabilistic selection based on weight
187
- * Higher weight = Higher chance of selection
188
- */
189
- export class WeightedStrategy implements LoadBalancingStrategy {
190
- next(candidates: KeyState[]): KeyState | null {
191
- if (candidates.length === 0) return null;
192
-
193
- const totalWeight = candidates.reduce((sum, k) => sum + k.weight, 0);
194
- let random = Math.random() * totalWeight;
195
-
196
- for (const key of candidates) {
197
- random -= key.weight;
198
- if (random <= 0) return key;
199
- }
200
-
201
- return candidates[0]; // Fallback
202
- }
203
- }
204
-
205
- /**
206
- * Latency Strategy: Pick lowest average latency with LRU tie-break
207
- */
208
- export class LatencyStrategy implements LoadBalancingStrategy {
209
- next(candidates: KeyState[]): KeyState | null {
210
- if (candidates.length === 0) return null;
211
- candidates.sort((a, b) => {
212
- if (a.averageLatency !== b.averageLatency) return a.averageLatency - b.averageLatency;
213
- return a.lastUsed - b.lastUsed; // LRU tie-break
214
- });
215
- return candidates[0];
216
- }
217
- }
218
-
219
- // ─── Semantic Engine ─────────────────────────────────────────────────────────
220
-
221
- /**
222
- * High-performance Vanilla Semantic Cache
223
- * Implements Cosine Similarity math from scratch.
224
- */
225
- export class SemanticCache {
226
- private entries: CacheEntry[] = [];
227
- private threshold: number;
228
- private ttlMs: number;
229
-
230
- constructor(threshold: number = 0.95, ttlMs: number = 24 * 60 * 60 * 1000) {
231
- this.threshold = threshold;
232
- this.ttlMs = ttlMs;
233
- }
234
-
235
- public set(prompt: string, vector: number[], response: any) {
236
- // Expire old entry for same prompt if exists
237
- this.entries = this.entries.filter(e => e.prompt !== prompt);
238
- this.entries.push({
239
- prompt,
240
- vector,
241
- response,
242
- timestamp: Date.now()
243
- });
244
- // Optional: Cap size to prevent memory leaks
245
- if (this.entries.length > 500) this.entries.shift();
246
- }
247
-
248
- public get(vector: number[]): any | null {
249
- const now = Date.now();
250
- let bestMatch: CacheEntry | null = null;
251
- let highestSimilarity = -1;
252
-
253
- for (let i = this.entries.length - 1; i >= 0; i--) {
254
- const entry = this.entries[i];
255
-
256
- // Check TTL
257
- if (now - entry.timestamp > this.ttlMs) {
258
- this.entries.splice(i, 1);
259
- continue;
260
- }
261
-
262
- const similarity = this.calculateCosineSimilarity(vector, entry.vector);
263
- if (similarity >= this.threshold && similarity > highestSimilarity) {
264
- highestSimilarity = similarity;
265
- bestMatch = entry;
266
- }
267
- }
268
-
269
- return bestMatch ? bestMatch.response : null;
270
- }
271
-
272
- /**
273
- * Vanilla Cosine Similarity: (A·B) / (||A|| * ||B||)
274
- */
275
- private calculateCosineSimilarity(vecA: number[], vecB: number[]): number {
276
- if (vecA.length !== vecB.length) return 0;
277
- let dotProduct = 0;
278
- let normA = 0;
279
- let normB = 0;
280
-
281
- for (let i = 0; i < vecA.length; i++) {
282
- dotProduct += vecA[i] * vecB[i];
283
- normA += vecA[i] * vecA[i];
284
- normB += vecB[i] * vecB[i];
285
- }
286
-
287
- const denominator = Math.sqrt(normA) * Math.sqrt(normB);
288
- return denominator === 0 ? 0 : dotProduct / denominator;
289
- }
290
- }
291
-
292
- // ─── Main Class ──────────────────────────────────────────────────────────────
293
-
294
- export class ApiKeyManager extends EventEmitter {
295
- private keys: KeyState[] = [];
296
- private storageKey = 'api_rotation_state_v2';
297
- private storage: any;
298
- private strategy: LoadBalancingStrategy;
299
- private fallbackFn?: () => any;
300
-
301
- // Bulkhead state
302
- private maxConcurrency: number;
303
- private activeCalls: number = 0;
304
-
305
- // Health check state
306
- private healthCheckFn?: (key: string) => Promise<boolean>;
307
- private healthCheckInterval?: ReturnType<typeof setInterval>;
308
-
309
- // Semantic Cache v4
310
- private semanticCache?: SemanticCache;
311
- private getEmbeddingFn?: (text: string) => Promise<number[]>;
312
- private _isResolvingEmbedding: boolean = false; // Recursion guard
313
-
314
- /**
315
- * Constructor supports both legacy positional args and new options object.
316
- *
317
- * @example Legacy (v1/v2 still works):
318
- * new ApiKeyManager(['key1', 'key2'], storage, strategy)
319
- *
320
- * @example New (v3):
321
- * new ApiKeyManager(keys, { storage, strategy, fallbackFn, concurrency })
322
- */
323
- constructor(
324
- initialKeys: string[] | { key: string; weight?: number; provider?: string }[],
325
- storageOrOptions?: any | ApiKeyManagerOptions,
326
- strategy?: LoadBalancingStrategy
327
- ) {
328
- super();
329
-
330
- // Detect if second arg is options object or legacy storage
331
- let options: ApiKeyManagerOptions = {};
332
- if (storageOrOptions && typeof storageOrOptions === 'object' && ('storage' in storageOrOptions || 'strategy' in storageOrOptions || 'fallbackFn' in storageOrOptions || 'concurrency' in storageOrOptions || 'semanticCache' in storageOrOptions)) {
333
- // New v3 options object
334
- options = storageOrOptions as ApiKeyManagerOptions;
335
- } else {
336
- // Legacy positional args
337
- options = {
338
- storage: storageOrOptions,
339
- strategy: strategy,
340
- };
341
- }
342
-
343
- this.storage = options.storage || {
344
- getItem: () => null,
345
- setItem: () => { },
346
- };
347
- this.strategy = options.strategy || new StandardStrategy();
348
- this.fallbackFn = options.fallbackFn;
349
- this.maxConcurrency = options.concurrency || Infinity;
350
-
351
- // Init Semantic Cache if provided
352
- if (options.semanticCache) {
353
- this.semanticCache = new SemanticCache(
354
- options.semanticCache.threshold,
355
- options.semanticCache.ttlMs
356
- );
357
- this.getEmbeddingFn = options.semanticCache.getEmbedding;
358
- }
359
-
360
- // Normalize input to objects
361
- let inputKeys: { key: string; weight?: number; provider?: string }[] = [];
362
- if (initialKeys.length > 0 && typeof initialKeys[0] === 'string') {
363
- inputKeys = (initialKeys as string[]).flatMap(k => k.split(',').map(s => ({ key: s.trim(), weight: 1.0, provider: 'default' })));
364
- } else {
365
- inputKeys = initialKeys as { key: string; weight?: number; provider?: string }[];
366
- }
367
-
368
- // Deduplicate
369
- const uniqueMap = new Map<string, { weight: number; provider: string }>();
370
- inputKeys.forEach(k => {
371
- if (k.key.length > 0) uniqueMap.set(k.key, { weight: k.weight ?? 1.0, provider: k.provider ?? 'default' });
372
- });
373
-
374
- if (uniqueMap.size < inputKeys.length) {
375
- console.warn(`[ApiKeyManager] Removed ${inputKeys.length - uniqueMap.size} duplicate/empty keys.`);
376
- }
377
-
378
- this.keys = Array.from(uniqueMap.entries()).map(([key, meta]) => ({
379
- key,
380
- failCount: 0,
381
- failedAt: null,
382
- isQuotaError: false,
383
- circuitState: 'CLOSED',
384
- lastUsed: 0,
385
- successCount: 0,
386
- totalRequests: 0,
387
- halfOpenTestTime: null,
388
- customCooldown: null,
389
- weight: meta.weight,
390
- averageLatency: 0,
391
- totalLatency: 0,
392
- latencySamples: 0,
393
- provider: meta.provider,
394
- }));
395
-
396
- this.loadState();
397
- }
398
-
399
- // ─── Error Classification ────────────────────────────────────────────────
400
-
401
- /**
402
- * CLASSIFIES an error to determine handling strategy
403
- */
404
- public classifyError(error: any, finishReason?: string): ErrorClassification {
405
- const status = error?.status || error?.response?.status;
406
- const message = error?.message || error?.error?.message || String(error);
407
-
408
- // 1. Check finishReason first
409
- if (finishReason === 'SAFETY') return { type: 'SAFETY', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
410
- if (finishReason === 'RECITATION') return { type: 'RECITATION', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
411
-
412
- // 2. Check timeout
413
- if (error instanceof TimeoutError || error?.name === 'TimeoutError') {
414
- return { type: 'TIMEOUT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
415
- }
416
-
417
- // 3. Check HTTP status codes
418
- if (status === 403 || ERROR_PATTERNS.isAuthError.test(message)) {
419
- return { type: 'AUTH', retryable: false, cooldownMs: Infinity, markKeyFailed: true, markKeyDead: true };
420
- }
421
- if (status === 429 || ERROR_PATTERNS.isQuotaError.test(message)) {
422
- const retryAfter = this.parseRetryAfter(error);
423
- return {
424
- type: 'QUOTA',
425
- retryable: true,
426
- cooldownMs: retryAfter || CONFIG.COOLDOWN_QUOTA,
427
- markKeyFailed: true,
428
- markKeyDead: false
429
- };
430
- }
431
- if (status === 400 || ERROR_PATTERNS.isBadRequest.test(message)) {
432
- return { type: 'BAD_REQUEST', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
433
- }
434
- if (ERROR_PATTERNS.isTransient.test(message) || [500, 502, 503, 504].includes(status)) {
435
- return { type: 'TRANSIENT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
436
- }
437
-
438
- return { type: 'UNKNOWN', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
439
- }
440
-
441
- private parseRetryAfter(error: any): number | null {
442
- const retryAfter = error?.response?.headers?.['retry-after'] ||
443
- error?.headers?.['retry-after'] ||
444
- error?.retryAfter;
445
-
446
- if (!retryAfter) return null;
447
-
448
- const seconds = parseInt(retryAfter, 10);
449
- if (!isNaN(seconds)) return seconds * 1000;
450
-
451
- const date = Date.parse(retryAfter);
452
- if (!isNaN(date)) return Math.max(0, date - Date.now());
453
-
454
- return null;
455
- }
456
-
457
- // ─── Cooldown ────────────────────────────────────────────────────────────
458
-
459
- private isOnCooldown(k: KeyState): boolean {
460
- if (k.circuitState === 'DEAD') return true;
461
- const now = Date.now();
462
-
463
- if (k.circuitState === 'OPEN') {
464
- if (k.halfOpenTestTime && now >= k.halfOpenTestTime) {
465
- k.circuitState = 'HALF_OPEN';
466
- this.emit('circuitHalfOpen', k.key);
467
- return false;
468
- }
469
- return true;
470
- }
471
-
472
- if (k.failedAt) {
473
- if (k.customCooldown && now - k.failedAt < k.customCooldown) return true;
474
- const cooldown = k.isQuotaError ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT;
475
- if (now - k.failedAt < cooldown) return true;
476
- }
477
-
478
- return false;
479
- }
480
-
481
- // ─── Key Selection ───────────────────────────────────────────────────────
482
-
483
- public getKey(): string | null {
484
- // 1. Filter out dead and cooling down keys
485
- const candidates = this.keys.filter(k => k.circuitState !== 'DEAD' && !this.isOnCooldown(k));
486
-
487
- if (candidates.length === 0) {
488
- // FALLBACK: Return oldest failed key (excluding DEAD)
489
- const nonDead = this.keys.filter(k => k.circuitState !== 'DEAD');
490
- if (nonDead.length === 0) {
491
- this.emit('allKeysExhausted');
492
- return null;
493
- }
494
- return nonDead.sort((a, b) => (a.failedAt || 0) - (b.failedAt || 0))[0]?.key || null;
495
- }
496
-
497
- // 2. Delegate to Strategy
498
- const selected = this.strategy.next(candidates);
499
-
500
- if (selected) {
501
- selected.lastUsed = Date.now();
502
- this.saveState();
503
- return selected.key;
504
- }
505
- return null;
506
- }
507
-
508
- /**
509
- * Get a key filtered by provider tag
510
- */
511
- public getKeyByProvider(provider: string): string | null {
512
- const candidates = this.keys.filter(k =>
513
- k.provider === provider && k.circuitState !== 'DEAD' && !this.isOnCooldown(k)
514
- );
515
-
516
- if (candidates.length === 0) return null;
517
-
518
- const selected = this.strategy.next(candidates);
519
- if (selected) {
520
- selected.lastUsed = Date.now();
521
- this.saveState();
522
- return selected.key;
523
- }
524
- return null;
525
- }
526
-
527
- public getKeyCount(): number {
528
- return this.keys.filter(k => k.circuitState !== 'DEAD').length;
529
- }
530
-
531
- // ─── Mark Success / Failed ───────────────────────────────────────────────
532
-
533
- /**
534
- * Mark success AND update latency stats
535
- * @param durationMs Duration of the request in milliseconds
536
- */
537
- public markSuccess(key: string, durationMs?: number) {
538
- const k = this.keys.find(x => x.key === key);
539
- if (!k) return;
540
-
541
- const wasRecovering = k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD';
542
- if (wasRecovering) {
543
- console.log(`[Key Recovered] ...${key.slice(-4)}`);
544
- this.emit('keyRecovered', key);
545
- }
546
-
547
- k.circuitState = 'CLOSED';
548
- k.failCount = 0;
549
- k.failedAt = null;
550
- k.isQuotaError = false;
551
- k.customCooldown = null;
552
- k.successCount++;
553
- k.totalRequests++;
554
-
555
- if (durationMs !== undefined) {
556
- k.totalLatency += durationMs;
557
- k.latencySamples++;
558
- k.averageLatency = k.totalLatency / k.latencySamples;
559
- }
560
-
561
- this.saveState();
562
- }
563
-
564
- public markFailed(key: string, classification: ErrorClassification) {
565
- const k = this.keys.find(x => x.key === key);
566
- if (!k || k.circuitState === 'DEAD') return;
567
- if (!classification.markKeyFailed) return;
568
-
569
- k.failedAt = Date.now();
570
- k.failCount++;
571
- k.totalRequests++;
572
- k.isQuotaError = classification.type === 'QUOTA';
573
- k.customCooldown = classification.cooldownMs || null;
574
-
575
- if (classification.markKeyDead) {
576
- k.circuitState = 'DEAD';
577
- console.error(`[Key DEAD] ...${key.slice(-4)} - Permanently removed`);
578
- this.emit('keyDead', key);
579
- } else {
580
- // State Transitions
581
- if (k.circuitState === 'HALF_OPEN') {
582
- k.circuitState = 'OPEN';
583
- k.halfOpenTestTime = Date.now() + CONFIG.HALF_OPEN_TEST_DELAY;
584
- this.emit('circuitOpen', key);
585
- } else if (k.failCount >= CONFIG.MAX_CONSECUTIVE_FAILURES || classification.type === 'QUOTA') {
586
- k.circuitState = 'OPEN';
587
- k.halfOpenTestTime = Date.now() + (classification.cooldownMs || CONFIG.HALF_OPEN_TEST_DELAY);
588
- this.emit('circuitOpen', key);
589
- }
590
- }
591
- this.saveState();
592
- }
593
-
594
- public markFailedLegacy(key: string, isQuota: boolean = false) {
595
- this.markFailed(key, {
596
- type: isQuota ? 'QUOTA' : 'TRANSIENT',
597
- retryable: true,
598
- cooldownMs: isQuota ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT,
599
- markKeyFailed: true,
600
- markKeyDead: false,
601
- });
602
- }
603
-
604
- // ─── Backoff ─────────────────────────────────────────────────────────────
605
-
606
- public calculateBackoff(attempt: number): number {
607
- const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
608
- const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
609
- const jitter = Math.random() * 1000;
610
- return capped + jitter;
611
- }
612
-
613
- // ─── Stats ───────────────────────────────────────────────────────────────
614
-
615
- public getStats(): ApiKeyManagerStats {
616
- const total = this.keys.length;
617
- const dead = this.keys.filter(k => k.circuitState === 'DEAD').length;
618
- const cooling = this.keys.filter(k => k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN').length;
619
- const healthy = total - dead - cooling;
620
- return { total, healthy, cooling, dead };
621
- }
622
-
623
- public _getKeys(): KeyState[] { return this.keys; }
624
-
625
- // ─── execute() Wrapper ───────────────────────────────────────────────────
626
-
627
- /**
628
- * Wraps the entire API call lifecycle into a single method.
629
- *
630
- * @example
631
- * const result = await manager.execute(
632
- * (key) => fetch(`https://api.example.com?key=${key}`),
633
- * { maxRetries: 3, timeoutMs: 5000 }
634
- * );
635
- */
636
- public async execute<T>(
637
- fn: (key: string, signal?: AbortSignal) => Promise<T>,
638
- options?: ExecuteOptions & { prompt?: string }
639
- ): Promise<T> {
640
- const maxRetries = options?.maxRetries ?? 0;
641
- const timeoutMs = options?.timeoutMs;
642
- const finishReason = options?.finishReason;
643
- const prompt = options?.prompt;
644
- const provider = options?.provider;
645
-
646
- // 1. Semantic Cache Check (Mastermind Edition)
647
- // Guard: skip cache if we're already resolving an embedding to prevent
648
- // infinite recursion when getEmbeddingFn calls execute() internally.
649
- let currentPromptVector: number[] | null = null;
650
- if (this.semanticCache && this.getEmbeddingFn && prompt && !this._isResolvingEmbedding) {
651
- try {
652
- this._isResolvingEmbedding = true;
653
- currentPromptVector = await this.getEmbeddingFn(prompt);
654
- const cachedResponse = this.semanticCache.get(currentPromptVector);
655
- if (cachedResponse !== null) {
656
- console.log(`[Semantic Cache HIT] for prompt: "${prompt.slice(0, 30)}..."`);
657
- this.emit('executeSuccess', 'CACHE_HIT', 0);
658
- return cachedResponse as T;
659
- }
660
- } catch (e) {
661
- console.warn('[Semantic Cache Check Failed] Proceeding to live API', e);
662
- } finally {
663
- this._isResolvingEmbedding = false;
664
- }
665
- }
666
-
667
- // 2. Bulkhead check
668
- if (this.activeCalls >= this.maxConcurrency) {
669
- this.emit('bulkheadRejected');
670
- throw new BulkheadRejectionError();
671
- }
672
-
673
- this.activeCalls++;
674
- try {
675
- const result = await this._executeWithRetry(fn, maxRetries, timeoutMs, finishReason, provider);
676
-
677
- // 3. Store in Semantic Cache on success
678
- if (this.semanticCache && prompt && currentPromptVector) {
679
- this.semanticCache.set(prompt, currentPromptVector, result);
680
- }
681
-
682
- return result;
683
- } finally {
684
- this.activeCalls--;
685
- }
686
- }
687
-
688
- /**
689
- * Executes a streaming function (AsyncGenerator) with retry logic and semantic caching.
690
- *
691
- * @example
692
- * const stream = await manager.executeStream(async (key) => {
693
- * return await gemini.generateContentStream({ prompt: "..." });
694
- * }, { prompt: "..." });
695
- *
696
- * for await (const chunk of stream) {
697
- * console.log(chunk.text());
698
- * }
699
- */
700
- public async *executeStream<T>(
701
- fn: (key: string, signal?: AbortSignal) => AsyncGenerator<T, any, unknown>,
702
- options?: ExecuteOptions & { prompt?: string }
703
- ): AsyncGenerator<T, any, unknown> {
704
- const maxRetries = options?.maxRetries ?? 0;
705
- const timeoutMs = options?.timeoutMs;
706
- const finishReason = options?.finishReason;
707
- const prompt = options?.prompt;
708
- const provider = options?.provider;
709
-
710
- // 1. Semantic Cache Check
711
- let currentPromptVector: number[] | null = null;
712
- if (this.semanticCache && this.getEmbeddingFn && prompt && !this._isResolvingEmbedding) {
713
- try {
714
- this._isResolvingEmbedding = true;
715
- currentPromptVector = await this.getEmbeddingFn(prompt);
716
- const cachedResponse = this.semanticCache.get(currentPromptVector);
717
- if (cachedResponse !== null) {
718
- console.log(`[Semantic Cache HIT] Streaming cached response for prompt: "${prompt.slice(0, 30)}..."`);
719
- this.emit('executeSuccess', 'CACHE_HIT_STREAM', 0);
720
- // Replay full response as a single chunk (or iterate if it's an array)
721
- if (Array.isArray(cachedResponse)) {
722
- for (const chunk of cachedResponse) yield chunk as T;
723
- } else {
724
- yield cachedResponse as T;
725
- }
726
- return;
727
- }
728
- } catch (e) {
729
- console.warn('[Semantic Cache Check Failed] Proceeding to live stream', e);
730
- } finally {
731
- this._isResolvingEmbedding = false;
732
- }
733
- }
734
-
735
- // 2. Bulkhead check
736
- if (this.activeCalls >= this.maxConcurrency) {
737
- this.emit('bulkheadRejected');
738
- throw new BulkheadRejectionError();
739
- }
740
-
741
- this.activeCalls++;
742
- const accumulatedChunks: T[] = [];
743
- let lastError: any;
744
-
745
- try {
746
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
747
- const key = provider ? this.getKeyByProvider(provider) : this.getKey();
748
-
749
- if (!key) {
750
- if (this.fallbackFn) {
751
- this.emit('fallback', 'all keys exhausted (stream)');
752
- yield this.fallbackFn();
753
- return;
754
- }
755
- throw new AllKeysExhaustedError();
756
- }
757
-
758
- let iterator: AsyncGenerator<T, any, unknown>;
759
- try {
760
- // Start the generator
761
- iterator = fn(key);
762
-
763
- // PRIME THE ITERATOR:
764
- // Try to get the first chunk. This forces the generator body to
765
- // execute until the first 'yield' or until it throws.
766
- const firstResult = await iterator.next();
767
-
768
- // If we got here, the INITIAL connection/logic succeeded!
769
- if (firstResult.done) return;
770
-
771
- // Yield first chunk
772
- yield firstResult.value;
773
- if (this.semanticCache && prompt) accumulatedChunks.push(firstResult.value);
774
-
775
- // Yield rest of chunks
776
- for await (const chunk of iterator) {
777
- yield chunk;
778
- if (this.semanticCache && prompt) accumulatedChunks.push(chunk);
779
- }
780
-
781
- // Success! Store in cache
782
- if (this.semanticCache && prompt && currentPromptVector && accumulatedChunks.length > 0) {
783
- this.semanticCache.set(prompt, currentPromptVector, accumulatedChunks);
784
- }
785
-
786
- return; // Full success, exit retry loop
787
-
788
- } catch (error: any) {
789
- lastError = error;
790
- const classification = this.classifyError(error, finishReason);
791
-
792
- this.markFailed(key, classification);
793
- this.emit('executeFailed', key, error);
794
-
795
- // Note: If we already yielded the FIRST chunk, we CANNOT retry the connection
796
- // because the user has already received data. Mid-stream failures propagate.
797
- if (accumulatedChunks.length > 0) {
798
- throw error;
799
- }
800
-
801
- if (!classification.retryable || attempt >= maxRetries) {
802
- if (this.fallbackFn && attempt >= maxRetries) {
803
- this.emit('fallback', 'max retries exceeded (stream)');
804
- yield this.fallbackFn();
805
- return;
806
- }
807
- throw error;
808
- }
809
-
810
- const delay = this.calculateBackoff(attempt);
811
- this.emit('retry', key, attempt + 1, delay);
812
- await this._sleep(delay);
813
- }
814
- }
815
- } finally {
816
- this.activeCalls--;
817
- }
818
-
819
- throw lastError;
820
- }
821
-
822
- private async _executeWithRetry<T>(
823
- fn: (key: string, signal?: AbortSignal) => Promise<T>,
824
- maxRetries: number,
825
- timeoutMs?: number,
826
- finishReason?: string,
827
- provider?: string
828
- ): Promise<T> {
829
- let lastError: any;
830
-
831
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
832
- const key = provider ? this.getKeyByProvider(provider) : this.getKey();
833
-
834
- if (!key) {
835
- // All keys exhausted — try fallback
836
- if (this.fallbackFn) {
837
- this.emit('fallback', 'all keys exhausted');
838
- return this.fallbackFn();
839
- }
840
- throw new AllKeysExhaustedError();
841
- }
842
-
843
- try {
844
- const start = Date.now();
845
- let result: T;
846
-
847
- if (timeoutMs) {
848
- result = await this._executeWithTimeout(fn, key, timeoutMs);
849
- } else {
850
- result = await fn(key);
851
- }
852
-
853
- const duration = Date.now() - start;
854
- this.markSuccess(key, duration);
855
- this.emit('executeSuccess', key, duration);
856
- return result;
857
-
858
- } catch (error: any) {
859
- lastError = error;
860
- const classification = this.classifyError(error, finishReason);
861
-
862
- this.markFailed(key, classification);
863
- this.emit('executeFailed', key, error);
864
-
865
- if (!classification.retryable || attempt >= maxRetries) {
866
- // Non-retryable or out of retries
867
- if (this.fallbackFn && attempt >= maxRetries) {
868
- this.emit('fallback', 'max retries exceeded');
869
- return this.fallbackFn();
870
- }
871
- throw error;
872
- }
873
-
874
- // Retry with backoff
875
- const delay = this.calculateBackoff(attempt);
876
- this.emit('retry', key, attempt + 1, delay);
877
- await this._sleep(delay);
878
- }
879
- }
880
-
881
- // Should not reach here, but safety net
882
- throw lastError;
883
- }
884
-
885
- private async _executeWithTimeout<T>(
886
- fn: (key: string, signal?: AbortSignal) => Promise<T>,
887
- key: string,
888
- timeoutMs: number
889
- ): Promise<T> {
890
- const controller = new AbortController();
891
- const timer = setTimeout(() => controller.abort(), timeoutMs);
892
-
893
- try {
894
- const result = await Promise.race([
895
- fn(key, controller.signal),
896
- new Promise<never>((_, reject) => {
897
- controller.signal.addEventListener('abort', () => {
898
- reject(new TimeoutError(timeoutMs));
899
- });
900
- })
901
- ]);
902
- return result;
903
- } finally {
904
- clearTimeout(timer);
905
- }
906
- }
907
-
908
- private _sleep(ms: number): Promise<void> {
909
- return new Promise(resolve => {
910
- const timer = setTimeout(resolve, ms);
911
- if (timer.unref) timer.unref();
912
- });
913
- }
914
-
915
- // ─── Health Checks ───────────────────────────────────────────────────────
916
-
917
- /**
918
- * Set a health check function that tests if a key is operational
919
- */
920
- public setHealthCheck(fn: (key: string) => Promise<boolean>) {
921
- this.healthCheckFn = fn;
922
- }
923
-
924
- /**
925
- * Start periodic health checks
926
- * @param intervalMs How often to run health checks (default: 60s)
927
- */
928
- public startHealthChecks(intervalMs: number = 60_000) {
929
- this.stopHealthChecks(); // Clear any existing interval
930
- this.healthCheckInterval = setInterval(() => this._runHealthChecks(), intervalMs);
931
- if (this.healthCheckInterval.unref) this.healthCheckInterval.unref();
932
- }
933
-
934
- /**
935
- * Stop periodic health checks
936
- */
937
- public stopHealthChecks() {
938
- if (this.healthCheckInterval) {
939
- clearInterval(this.healthCheckInterval);
940
- this.healthCheckInterval = undefined;
941
- }
942
- }
943
-
944
- private async _runHealthChecks() {
945
- if (!this.healthCheckFn) return;
946
-
947
- // Check non-DEAD keys that are in OPEN or HALF_OPEN state
948
- const keysToCheck = this.keys.filter(k =>
949
- k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN'
950
- );
951
-
952
- for (const k of keysToCheck) {
953
- try {
954
- const healthy = await this.healthCheckFn(k.key);
955
- if (healthy) {
956
- this.markSuccess(k.key);
957
- this.emit('healthCheckPassed', k.key);
958
- } else {
959
- this.emit('healthCheckFailed', k.key, new Error('Health check returned false'));
960
- }
961
- } catch (error) {
962
- this.emit('healthCheckFailed', k.key, error);
963
- }
964
- }
965
- }
966
-
967
- // ─── Persistence ─────────────────────────────────────────────────────────
968
-
969
- private saveState() {
970
- if (!this.storage) return;
971
- const state = this.keys.reduce((acc, k) => ({
972
- ...acc,
973
- [k.key]: {
974
- failCount: k.failCount,
975
- failedAt: k.failedAt,
976
- isQuotaError: k.isQuotaError,
977
- circuitState: k.circuitState,
978
- lastUsed: k.lastUsed,
979
- successCount: k.successCount,
980
- totalRequests: k.totalRequests,
981
- customCooldown: k.customCooldown,
982
- weight: k.weight,
983
- averageLatency: k.averageLatency,
984
- totalLatency: k.totalLatency,
985
- latencySamples: k.latencySamples,
986
- provider: k.provider
987
- }
988
- }), {});
989
- this.storage.setItem(this.storageKey, JSON.stringify(state));
990
- }
991
-
992
- private loadState() {
993
- if (!this.storage) return;
994
- try {
995
- const raw = this.storage.getItem(this.storageKey);
996
- if (!raw) return;
997
- const data = JSON.parse(raw);
998
- this.keys.forEach(k => {
999
- if (data[k.key]) Object.assign(k, data[k.key]);
1000
- });
1001
- } catch (e) {
1002
- console.error("Failed to load key state");
1003
- }
1004
- }
1005
- }
1
+ /**
2
+ * Universal ApiKeyManager v5.0 — Ecosystem Edition
3
+ * Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff, Strategies,
4
+ * Event Emitter, Fallback, execute(), Timeout, Auto-Retry, Provider Tags,
5
+ * Health Checks, Bulkhead/Concurrency
6
+ * NEW in v5.0: Provider Presets (GeminiManager, OpenAIManager, MultiManager),
7
+ * Built-in Persistence (FileStorage, MemoryStorage)
8
+ * Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
9
+ */
10
+
11
+ import { EventEmitter } from 'events';
12
+
13
+ // ─── Re-exports: Persistence ─────────────────────────────────────────────────
14
+ // Persistence adapters can be imported from root or via subpath
15
+ export { FileStorage } from './persistence/file';
16
+ export type { FileStorageOptions } from './persistence/file';
17
+ export { MemoryStorage } from './persistence/memory';
18
+
19
+ // ─── Presets ─────────────────────────────────────────────────────────────────
20
+ // Presets are available via subpath imports to avoid circular dependencies:
21
+ // import { GeminiManager } from '@splashcodex/api-key-manager/presets/gemini';
22
+ // import { OpenAIManager } from '@splashcodex/api-key-manager/presets/openai';
23
+ // import { MultiManager } from '@splashcodex/api-key-manager/presets/multi';
24
+ // Or import all at once:
25
+ // import { GeminiManager, OpenAIManager, MultiManager } from '@splashcodex/api-key-manager/presets';
26
+
27
+
28
+
29
+ // ─── Interfaces & Types ──────────────────────────────────────────────────────
30
+
31
+ export interface KeyState {
32
+ key: string;
33
+ failCount: number; // Consecutive failures
34
+ failedAt: number | null; // Timestamp of last failure
35
+ isQuotaError: boolean; // Was last error a 429?
36
+ circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN' | 'DEAD';
37
+ lastUsed: number;
38
+ successCount: number;
39
+ totalRequests: number;
40
+ halfOpenTestTime: number | null;
41
+ customCooldown: number | null; // From Retry-After header
42
+ // v2.0 Stats
43
+ weight: number; // 0.0 - 1.0 (Default 1.0)
44
+ averageLatency: number; // Rolling average latency in ms
45
+ totalLatency: number; // Sum of all latency checks (for calculating average)
46
+ latencySamples: number; // Number of samples
47
+ // v3.0 Fields
48
+ provider: string; // Provider tag (e.g. 'openai', 'gemini')
49
+ }
50
+
51
+ export type ErrorType =
52
+ | 'QUOTA' // 429 - Rotate key, respect cooldown
53
+ | 'TRANSIENT' // 500/503/504 - Retry with backoff
54
+ | 'AUTH' // 403 - Key is dead, remove from pool
55
+ | 'BAD_REQUEST' // 400 - Do not retry, fix request
56
+ | 'SAFETY' // finishReason: SAFETY - Not a key issue
57
+ | 'RECITATION' // finishReason: RECITATION - Not a key issue
58
+ | 'TIMEOUT' // Request timed out
59
+ | 'UNKNOWN'; // Catch-all
60
+
61
+ export interface ErrorClassification {
62
+ type: ErrorType;
63
+ retryable: boolean;
64
+ cooldownMs: number;
65
+ markKeyFailed: boolean;
66
+ markKeyDead: boolean;
67
+ }
68
+
69
+ export interface ApiKeyManagerStats {
70
+ total: number;
71
+ healthy: number;
72
+ cooling: number;
73
+ dead: number;
74
+ }
75
+
76
+ export interface ExecuteOptions {
77
+ timeoutMs?: number; // Timeout per attempt in ms
78
+ maxRetries?: number; // Max retry attempts (default: 0 = no retry)
79
+ finishReason?: string; // For Gemini finishReason handling
80
+ provider?: string; // Filter keys by provider (e.g. 'openai')
81
+ }
82
+
83
+ export interface ApiKeyManagerOptions {
84
+ storage?: any;
85
+ strategy?: LoadBalancingStrategy;
86
+ fallbackFn?: () => any;
87
+ concurrency?: number; // Max concurrent execute() calls
88
+ semanticCache?: {
89
+ threshold?: number; // Similarity threshold (0.0 - 1.0, default 0.95)
90
+ ttlMs?: number; // Cache TTL
91
+ getEmbedding: (text: string) => Promise<number[]>;
92
+ };
93
+ }
94
+
95
+ export interface CacheEntry {
96
+ vector: number[];
97
+ prompt: string;
98
+ response: any;
99
+ timestamp: number;
100
+ }
101
+
102
+ // ─── Event Types ─────────────────────────────────────────────────────────────
103
+
104
+ export interface ApiKeyManagerEventMap {
105
+ keyDead: (key: string) => void;
106
+ circuitOpen: (key: string) => void;
107
+ circuitHalfOpen: (key: string) => void;
108
+ keyRecovered: (key: string) => void;
109
+ fallback: (reason: string) => void;
110
+ allKeysExhausted: () => void;
111
+ retry: (key: string, attempt: number, delayMs: number) => void;
112
+ healthCheckFailed: (key: string, error: any) => void;
113
+ healthCheckPassed: (key: string) => void;
114
+ executeSuccess: (key: string, durationMs: number) => void;
115
+ executeFailed: (key: string, error: any) => void;
116
+ bulkheadRejected: () => void;
117
+ }
118
+
119
+ // ─── Config ──────────────────────────────────────────────────────────────────
120
+
121
+ const CONFIG = {
122
+ MAX_CONSECUTIVE_FAILURES: 5,
123
+ COOLDOWN_TRANSIENT: 60 * 1000, // 1 minute
124
+ COOLDOWN_QUOTA: 5 * 60 * 1000, // 5 minutes (default if no Retry-After)
125
+ COOLDOWN_QUOTA_DAILY: 60 * 60 * 1000, // 1 hour for RPD exhaustion
126
+ HALF_OPEN_TEST_DELAY: 60 * 1000, // 1 minute after open
127
+ MAX_BACKOFF: 64 * 1000, // 64 seconds max
128
+ BASE_BACKOFF: 1000, // 1 second base
129
+ DEAD_KEY_TTL: 60 * 60 * 1000, // 1 hour — DEAD keys get retested after this
130
+ };
131
+
132
+ // Error classification patterns
133
+ const ERROR_PATTERNS = {
134
+ isQuotaError: /429|quota|exhausted|resource.?exhausted|too.?many.?requests|rate.?limit/i,
135
+ isAuthError: /403|permission.?denied|invalid.?api.?key|unauthorized|unauthenticated/i,
136
+ isSafetyBlock: /safety|blocked|recitation|harmful/i,
137
+ isTransient: /500|502|503|504|internal|unavailable|deadline|timeout|overloaded/i,
138
+ isBadRequest: /400|invalid.?argument|failed.?precondition|malformed|not.?found|404/i,
139
+ };
140
+
141
+ // ─── Custom Errors ───────────────────────────────────────────────────────────
142
+
143
+ export class TimeoutError extends Error {
144
+ constructor(ms: number) {
145
+ super(`Request timed out after ${ms}ms`);
146
+ this.name = 'TimeoutError';
147
+ }
148
+ }
149
+
150
+ export class BulkheadRejectionError extends Error {
151
+ constructor() {
152
+ super('Bulkhead capacity exceeded — too many concurrent requests');
153
+ this.name = 'BulkheadRejectionError';
154
+ }
155
+ }
156
+
157
+ export class AllKeysExhaustedError extends Error {
158
+ constructor() {
159
+ super('All API keys exhausted — no healthy keys available');
160
+ this.name = 'AllKeysExhaustedError';
161
+ }
162
+ }
163
+
164
+ // ─── Strategies ──────────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Strategy Interface for selecting the next key
168
+ */
169
+ export interface LoadBalancingStrategy {
170
+ next(candidates: KeyState[]): KeyState | null;
171
+ }
172
+
173
+ /**
174
+ * Standard Strategy: Least Failed > Least Recently Used
175
+ */
176
+ export class StandardStrategy implements LoadBalancingStrategy {
177
+ next(candidates: KeyState[]): KeyState | null {
178
+ candidates.sort((a, b) => {
179
+ if (a.failCount !== b.failCount) return a.failCount - b.failCount;
180
+ return a.lastUsed - b.lastUsed;
181
+ });
182
+ return candidates[0] || null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Weighted Strategy: Probabilistic selection based on weight
188
+ * Higher weight = Higher chance of selection
189
+ */
190
+ export class WeightedStrategy implements LoadBalancingStrategy {
191
+ next(candidates: KeyState[]): KeyState | null {
192
+ if (candidates.length === 0) return null;
193
+
194
+ const totalWeight = candidates.reduce((sum, k) => sum + k.weight, 0);
195
+ let random = Math.random() * totalWeight;
196
+
197
+ for (const key of candidates) {
198
+ random -= key.weight;
199
+ if (random <= 0) return key;
200
+ }
201
+
202
+ return candidates[0]; // Fallback
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Latency Strategy: Pick lowest average latency with LRU tie-break
208
+ */
209
+ export class LatencyStrategy implements LoadBalancingStrategy {
210
+ next(candidates: KeyState[]): KeyState | null {
211
+ if (candidates.length === 0) return null;
212
+ candidates.sort((a, b) => {
213
+ if (a.averageLatency !== b.averageLatency) return a.averageLatency - b.averageLatency;
214
+ return a.lastUsed - b.lastUsed; // LRU tie-break
215
+ });
216
+ return candidates[0];
217
+ }
218
+ }
219
+
220
+ // ─── Semantic Engine ─────────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * High-performance Vanilla Semantic Cache
224
+ * Implements Cosine Similarity math from scratch.
225
+ */
226
+ export class SemanticCache {
227
+ private entries: CacheEntry[] = [];
228
+ private threshold: number;
229
+ private ttlMs: number;
230
+
231
+ constructor(threshold: number = 0.95, ttlMs: number = 24 * 60 * 60 * 1000) {
232
+ this.threshold = threshold;
233
+ this.ttlMs = ttlMs;
234
+ }
235
+
236
+ public set(prompt: string, vector: number[], response: any) {
237
+ // Expire old entry for same prompt if exists
238
+ this.entries = this.entries.filter(e => e.prompt !== prompt);
239
+ this.entries.push({
240
+ prompt,
241
+ vector,
242
+ response,
243
+ timestamp: Date.now()
244
+ });
245
+ // Optional: Cap size to prevent memory leaks
246
+ if (this.entries.length > 500) this.entries.shift();
247
+ }
248
+
249
+ public get(vector: number[]): any | null {
250
+ const now = Date.now();
251
+ let bestMatch: CacheEntry | null = null;
252
+ let highestSimilarity = -1;
253
+
254
+ for (let i = this.entries.length - 1; i >= 0; i--) {
255
+ const entry = this.entries[i];
256
+
257
+ // Check TTL
258
+ if (now - entry.timestamp > this.ttlMs) {
259
+ this.entries.splice(i, 1);
260
+ continue;
261
+ }
262
+
263
+ const similarity = this.calculateCosineSimilarity(vector, entry.vector);
264
+ if (similarity >= this.threshold && similarity > highestSimilarity) {
265
+ highestSimilarity = similarity;
266
+ bestMatch = entry;
267
+ }
268
+ }
269
+
270
+ return bestMatch ? bestMatch.response : null;
271
+ }
272
+
273
+ /**
274
+ * Vanilla Cosine Similarity: (A·B) / (||A|| * ||B||)
275
+ */
276
+ private calculateCosineSimilarity(vecA: number[], vecB: number[]): number {
277
+ if (vecA.length !== vecB.length) return 0;
278
+ let dotProduct = 0;
279
+ let normA = 0;
280
+ let normB = 0;
281
+
282
+ for (let i = 0; i < vecA.length; i++) {
283
+ dotProduct += vecA[i] * vecB[i];
284
+ normA += vecA[i] * vecA[i];
285
+ normB += vecB[i] * vecB[i];
286
+ }
287
+
288
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
289
+ return denominator === 0 ? 0 : dotProduct / denominator;
290
+ }
291
+ }
292
+
293
+ // ─── Main Class ──────────────────────────────────────────────────────────────
294
+
295
+ export class ApiKeyManager extends EventEmitter {
296
+ private keys: KeyState[] = [];
297
+ private storageKey = 'api_rotation_state_v2';
298
+ private storage: any;
299
+ private strategy: LoadBalancingStrategy;
300
+ private fallbackFn?: () => any;
301
+
302
+ // Bulkhead state
303
+ private maxConcurrency: number;
304
+ private activeCalls: number = 0;
305
+
306
+ // Health check state
307
+ private healthCheckFn?: (key: string) => Promise<boolean>;
308
+ private healthCheckInterval?: ReturnType<typeof setInterval>;
309
+
310
+ // Debounced persistence — avoids writeFileSync on every single call
311
+ private _saveTimer?: ReturnType<typeof setTimeout>;
312
+ private _saveDirty: boolean = false;
313
+
314
+ // Semantic Cache v4
315
+ private semanticCache?: SemanticCache;
316
+ private getEmbeddingFn?: (text: string) => Promise<number[]>;
317
+ private _isResolvingEmbedding: boolean = false; // Recursion guard
318
+
319
+ /**
320
+ * Constructor supports both legacy positional args and new options object.
321
+ *
322
+ * @example Legacy (v1/v2 — still works):
323
+ * new ApiKeyManager(['key1', 'key2'], storage, strategy)
324
+ *
325
+ * @example New (v3):
326
+ * new ApiKeyManager(keys, { storage, strategy, fallbackFn, concurrency })
327
+ */
328
+ constructor(
329
+ initialKeys: string[] | { key: string; weight?: number; provider?: string }[],
330
+ storageOrOptions?: any | ApiKeyManagerOptions,
331
+ strategy?: LoadBalancingStrategy
332
+ ) {
333
+ super();
334
+
335
+ // Detect if second arg is options object or legacy storage
336
+ let options: ApiKeyManagerOptions = {};
337
+ if (storageOrOptions && typeof storageOrOptions === 'object' && ('storage' in storageOrOptions || 'strategy' in storageOrOptions || 'fallbackFn' in storageOrOptions || 'concurrency' in storageOrOptions || 'semanticCache' in storageOrOptions)) {
338
+ // New v3 options object
339
+ options = storageOrOptions as ApiKeyManagerOptions;
340
+ } else {
341
+ // Legacy positional args
342
+ options = {
343
+ storage: storageOrOptions,
344
+ strategy: strategy,
345
+ };
346
+ }
347
+
348
+ this.storage = options.storage || {
349
+ getItem: () => null,
350
+ setItem: () => { },
351
+ };
352
+ this.strategy = options.strategy || new StandardStrategy();
353
+ this.fallbackFn = options.fallbackFn;
354
+ this.maxConcurrency = options.concurrency || Infinity;
355
+
356
+ // Init Semantic Cache if provided
357
+ if (options.semanticCache) {
358
+ this.semanticCache = new SemanticCache(
359
+ options.semanticCache.threshold,
360
+ options.semanticCache.ttlMs
361
+ );
362
+ this.getEmbeddingFn = options.semanticCache.getEmbedding;
363
+ }
364
+
365
+ // Normalize input to objects
366
+ let inputKeys: { key: string; weight?: number; provider?: string }[] = [];
367
+ if (initialKeys.length > 0 && typeof initialKeys[0] === 'string') {
368
+ inputKeys = (initialKeys as string[]).flatMap(k => k.split(',').map(s => ({ key: s.trim(), weight: 1.0, provider: 'default' })));
369
+ } else {
370
+ inputKeys = initialKeys as { key: string; weight?: number; provider?: string }[];
371
+ }
372
+
373
+ // Deduplicate
374
+ const uniqueMap = new Map<string, { weight: number; provider: string }>();
375
+ inputKeys.forEach(k => {
376
+ if (k.key.length > 0) uniqueMap.set(k.key, { weight: k.weight ?? 1.0, provider: k.provider ?? 'default' });
377
+ });
378
+
379
+ if (uniqueMap.size < inputKeys.length) {
380
+ console.warn(`[ApiKeyManager] Removed ${inputKeys.length - uniqueMap.size} duplicate/empty keys.`);
381
+ }
382
+
383
+ this.keys = Array.from(uniqueMap.entries()).map(([key, meta]) => ({
384
+ key,
385
+ failCount: 0,
386
+ failedAt: null,
387
+ isQuotaError: false,
388
+ circuitState: 'CLOSED',
389
+ lastUsed: 0,
390
+ successCount: 0,
391
+ totalRequests: 0,
392
+ halfOpenTestTime: null,
393
+ customCooldown: null,
394
+ weight: meta.weight,
395
+ averageLatency: 0,
396
+ totalLatency: 0,
397
+ latencySamples: 0,
398
+ provider: meta.provider,
399
+ }));
400
+
401
+ this.loadState();
402
+ }
403
+
404
+ // ─── Error Classification ────────────────────────────────────────────────
405
+
406
+ /**
407
+ * CLASSIFIES an error to determine handling strategy
408
+ */
409
+ public classifyError(error: any, finishReason?: string): ErrorClassification {
410
+ const status = error?.status || error?.response?.status;
411
+ const message = error?.message || error?.error?.message || String(error);
412
+
413
+ // 1. Check finishReason first
414
+ if (finishReason === 'SAFETY') return { type: 'SAFETY', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
415
+ if (finishReason === 'RECITATION') return { type: 'RECITATION', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
416
+
417
+ // 2. Check timeout
418
+ if (error instanceof TimeoutError || error?.name === 'TimeoutError') {
419
+ return { type: 'TIMEOUT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
420
+ }
421
+
422
+ // 3. Check HTTP status codes
423
+ if (status === 403 || ERROR_PATTERNS.isAuthError.test(message)) {
424
+ return { type: 'AUTH', retryable: false, cooldownMs: Infinity, markKeyFailed: true, markKeyDead: true };
425
+ }
426
+ if (status === 429 || ERROR_PATTERNS.isQuotaError.test(message)) {
427
+ const retryAfter = this.parseRetryAfter(error);
428
+ return {
429
+ type: 'QUOTA',
430
+ retryable: true,
431
+ cooldownMs: retryAfter || CONFIG.COOLDOWN_QUOTA,
432
+ markKeyFailed: true,
433
+ markKeyDead: false
434
+ };
435
+ }
436
+ if (status === 400 || ERROR_PATTERNS.isBadRequest.test(message)) {
437
+ return { type: 'BAD_REQUEST', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
438
+ }
439
+ if (ERROR_PATTERNS.isTransient.test(message) || [500, 502, 503, 504].includes(status)) {
440
+ return { type: 'TRANSIENT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
441
+ }
442
+
443
+ return { type: 'UNKNOWN', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
444
+ }
445
+
446
+ private parseRetryAfter(error: any): number | null {
447
+ const retryAfter = error?.response?.headers?.['retry-after'] ||
448
+ error?.headers?.['retry-after'] ||
449
+ error?.retryAfter;
450
+
451
+ if (!retryAfter) return null;
452
+
453
+ const seconds = parseInt(retryAfter, 10);
454
+ if (!isNaN(seconds)) return seconds * 1000;
455
+
456
+ const date = Date.parse(retryAfter);
457
+ if (!isNaN(date)) return Math.max(0, date - Date.now());
458
+
459
+ return null;
460
+ }
461
+
462
+ // ─── Cooldown ────────────────────────────────────────────────────────────
463
+
464
+ private isOnCooldown(k: KeyState): boolean {
465
+ if (k.circuitState === 'DEAD') return true;
466
+ const now = Date.now();
467
+
468
+ if (k.circuitState === 'OPEN') {
469
+ if (k.halfOpenTestTime && now >= k.halfOpenTestTime) {
470
+ k.circuitState = 'HALF_OPEN';
471
+ this.emit('circuitHalfOpen', k.key);
472
+ return false;
473
+ }
474
+ return true;
475
+ }
476
+
477
+ if (k.failedAt) {
478
+ if (k.customCooldown && now - k.failedAt < k.customCooldown) return true;
479
+ const cooldown = k.isQuotaError ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT;
480
+ if (now - k.failedAt < cooldown) return true;
481
+ }
482
+
483
+ return false;
484
+ }
485
+
486
+ // ─── Key Selection ───────────────────────────────────────────────────────
487
+
488
+ public getKey(): string | null {
489
+ // 1. Filter out dead and cooling down keys
490
+ const candidates = this.keys.filter(k => k.circuitState !== 'DEAD' && !this.isOnCooldown(k));
491
+
492
+ if (candidates.length === 0) {
493
+ // FALLBACK: Return oldest failed key (excluding DEAD)
494
+ const nonDead = this.keys.filter(k => k.circuitState !== 'DEAD');
495
+ if (nonDead.length === 0) {
496
+ this.emit('allKeysExhausted');
497
+ return null;
498
+ }
499
+ return nonDead.sort((a, b) => (a.failedAt || 0) - (b.failedAt || 0))[0]?.key || null;
500
+ }
501
+
502
+ // 2. Delegate to Strategy
503
+ const selected = this.strategy.next(candidates);
504
+
505
+ if (selected) {
506
+ selected.lastUsed = Date.now();
507
+ this.saveState();
508
+ return selected.key;
509
+ }
510
+ return null;
511
+ }
512
+
513
+ /**
514
+ * Get a key filtered by provider tag
515
+ */
516
+ public getKeyByProvider(provider: string): string | null {
517
+ const candidates = this.keys.filter(k =>
518
+ k.provider === provider && k.circuitState !== 'DEAD' && !this.isOnCooldown(k)
519
+ );
520
+
521
+ if (candidates.length === 0) return null;
522
+
523
+ const selected = this.strategy.next(candidates);
524
+ if (selected) {
525
+ selected.lastUsed = Date.now();
526
+ this.saveState();
527
+ return selected.key;
528
+ }
529
+ return null;
530
+ }
531
+
532
+ public getKeyCount(): number {
533
+ return this.keys.filter(k => k.circuitState !== 'DEAD').length;
534
+ }
535
+
536
+ // ─── Mark Success / Failed ───────────────────────────────────────────────
537
+
538
+ /**
539
+ * Mark success AND update latency stats
540
+ * @param durationMs Duration of the request in milliseconds
541
+ */
542
+ public markSuccess(key: string, durationMs?: number) {
543
+ const k = this.keys.find(x => x.key === key);
544
+ if (!k) return;
545
+
546
+ const wasRecovering = k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD';
547
+ if (wasRecovering) {
548
+ console.log(`[Key Recovered] ...${key.slice(-4)}`);
549
+ this.emit('keyRecovered', key);
550
+ }
551
+
552
+ k.circuitState = 'CLOSED';
553
+ k.failCount = 0;
554
+ k.failedAt = null;
555
+ k.isQuotaError = false;
556
+ k.customCooldown = null;
557
+ k.successCount++;
558
+ k.totalRequests++;
559
+
560
+ if (durationMs !== undefined) {
561
+ k.totalLatency += durationMs;
562
+ k.latencySamples++;
563
+ k.averageLatency = k.totalLatency / k.latencySamples;
564
+ }
565
+
566
+ this.saveState();
567
+ }
568
+
569
+ public markFailed(key: string, classification: ErrorClassification) {
570
+ const k = this.keys.find(x => x.key === key);
571
+ if (!k || k.circuitState === 'DEAD') return;
572
+ if (!classification.markKeyFailed) return;
573
+
574
+ k.failedAt = Date.now();
575
+ k.failCount++;
576
+ k.totalRequests++;
577
+ k.isQuotaError = classification.type === 'QUOTA';
578
+ k.customCooldown = classification.cooldownMs || null;
579
+
580
+ if (classification.markKeyDead) {
581
+ k.circuitState = 'DEAD';
582
+ console.error(`[Key DEAD] ...${key.slice(-4)} - Permanently removed`);
583
+ this.emit('keyDead', key);
584
+ } else {
585
+ // State Transitions
586
+ if (k.circuitState === 'HALF_OPEN') {
587
+ k.circuitState = 'OPEN';
588
+ k.halfOpenTestTime = Date.now() + CONFIG.HALF_OPEN_TEST_DELAY;
589
+ this.emit('circuitOpen', key);
590
+ } else if (k.failCount >= CONFIG.MAX_CONSECUTIVE_FAILURES || classification.type === 'QUOTA') {
591
+ k.circuitState = 'OPEN';
592
+ k.halfOpenTestTime = Date.now() + (classification.cooldownMs || CONFIG.HALF_OPEN_TEST_DELAY);
593
+ this.emit('circuitOpen', key);
594
+ }
595
+ }
596
+ this.saveState();
597
+ }
598
+
599
+ public markFailedLegacy(key: string, isQuota: boolean = false) {
600
+ this.markFailed(key, {
601
+ type: isQuota ? 'QUOTA' : 'TRANSIENT',
602
+ retryable: true,
603
+ cooldownMs: isQuota ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT,
604
+ markKeyFailed: true,
605
+ markKeyDead: false,
606
+ });
607
+ }
608
+
609
+ // ─── Backoff ─────────────────────────────────────────────────────────────
610
+
611
+ public calculateBackoff(attempt: number): number {
612
+ const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
613
+ const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
614
+ const jitter = Math.random() * 1000;
615
+ return capped + jitter;
616
+ }
617
+
618
+ // ─── Stats ───────────────────────────────────────────────────────────────
619
+
620
+ public getStats(): ApiKeyManagerStats {
621
+ const total = this.keys.length;
622
+ const dead = this.keys.filter(k => k.circuitState === 'DEAD').length;
623
+ const cooling = this.keys.filter(k => k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN').length;
624
+ const healthy = total - dead - cooling;
625
+ return { total, healthy, cooling, dead };
626
+ }
627
+
628
+ public _getKeys(): KeyState[] { return this.keys; }
629
+
630
+ // ─── execute() Wrapper ───────────────────────────────────────────────────
631
+
632
+ /**
633
+ * Wraps the entire API call lifecycle into a single method.
634
+ *
635
+ * @example
636
+ * const result = await manager.execute(
637
+ * (key) => fetch(`https://api.example.com?key=${key}`),
638
+ * { maxRetries: 3, timeoutMs: 5000 }
639
+ * );
640
+ */
641
+ public async execute<T>(
642
+ fn: (key: string, signal?: AbortSignal) => Promise<T>,
643
+ options?: ExecuteOptions & { prompt?: string }
644
+ ): Promise<T> {
645
+ const maxRetries = options?.maxRetries ?? 0;
646
+ const timeoutMs = options?.timeoutMs;
647
+ const finishReason = options?.finishReason;
648
+ const prompt = options?.prompt;
649
+ const provider = options?.provider;
650
+
651
+ // 1. Semantic Cache Check (Mastermind Edition)
652
+ // Guard: skip cache if we're already resolving an embedding to prevent
653
+ // infinite recursion when getEmbeddingFn calls execute() internally.
654
+ let currentPromptVector: number[] | null = null;
655
+ if (this.semanticCache && this.getEmbeddingFn && prompt && !this._isResolvingEmbedding) {
656
+ try {
657
+ this._isResolvingEmbedding = true;
658
+ currentPromptVector = await this.getEmbeddingFn(prompt);
659
+ const cachedResponse = this.semanticCache.get(currentPromptVector);
660
+ if (cachedResponse !== null) {
661
+ console.log(`[Semantic Cache HIT] for prompt: "${prompt.slice(0, 30)}..."`);
662
+ this.emit('executeSuccess', 'CACHE_HIT', 0);
663
+ return cachedResponse as T;
664
+ }
665
+ } catch (e) {
666
+ console.warn('[Semantic Cache Check Failed] Proceeding to live API', e);
667
+ } finally {
668
+ this._isResolvingEmbedding = false;
669
+ }
670
+ }
671
+
672
+ // 2. Bulkhead check
673
+ if (this.activeCalls >= this.maxConcurrency) {
674
+ this.emit('bulkheadRejected');
675
+ throw new BulkheadRejectionError();
676
+ }
677
+
678
+ this.activeCalls++;
679
+ try {
680
+ const result = await this._executeWithRetry(fn, maxRetries, timeoutMs, finishReason, provider);
681
+
682
+ // 3. Store in Semantic Cache on success
683
+ if (this.semanticCache && prompt && currentPromptVector) {
684
+ this.semanticCache.set(prompt, currentPromptVector, result);
685
+ }
686
+
687
+ return result;
688
+ } finally {
689
+ this.activeCalls--;
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Executes a streaming function (AsyncGenerator) with retry logic and semantic caching.
695
+ *
696
+ * @example
697
+ * const stream = await manager.executeStream(async (key) => {
698
+ * return await gemini.generateContentStream({ prompt: "..." });
699
+ * }, { prompt: "..." });
700
+ *
701
+ * for await (const chunk of stream) {
702
+ * console.log(chunk.text());
703
+ * }
704
+ */
705
+ public async *executeStream<T>(
706
+ fn: (key: string, signal?: AbortSignal) => AsyncGenerator<T, any, unknown>,
707
+ options?: ExecuteOptions & { prompt?: string }
708
+ ): AsyncGenerator<T, any, unknown> {
709
+ const maxRetries = options?.maxRetries ?? 0;
710
+ const timeoutMs = options?.timeoutMs;
711
+ const finishReason = options?.finishReason;
712
+ const prompt = options?.prompt;
713
+ const provider = options?.provider;
714
+
715
+ // 1. Semantic Cache Check
716
+ let currentPromptVector: number[] | null = null;
717
+ if (this.semanticCache && this.getEmbeddingFn && prompt && !this._isResolvingEmbedding) {
718
+ try {
719
+ this._isResolvingEmbedding = true;
720
+ currentPromptVector = await this.getEmbeddingFn(prompt);
721
+ const cachedResponse = this.semanticCache.get(currentPromptVector);
722
+ if (cachedResponse !== null) {
723
+ console.log(`[Semantic Cache HIT] Streaming cached response for prompt: "${prompt.slice(0, 30)}..."`);
724
+ this.emit('executeSuccess', 'CACHE_HIT_STREAM', 0);
725
+ // Replay full response as a single chunk (or iterate if it's an array)
726
+ if (Array.isArray(cachedResponse)) {
727
+ for (const chunk of cachedResponse) yield chunk as T;
728
+ } else {
729
+ yield cachedResponse as T;
730
+ }
731
+ return;
732
+ }
733
+ } catch (e) {
734
+ console.warn('[Semantic Cache Check Failed] Proceeding to live stream', e);
735
+ } finally {
736
+ this._isResolvingEmbedding = false;
737
+ }
738
+ }
739
+
740
+ // 2. Bulkhead check
741
+ if (this.activeCalls >= this.maxConcurrency) {
742
+ this.emit('bulkheadRejected');
743
+ throw new BulkheadRejectionError();
744
+ }
745
+
746
+ this.activeCalls++;
747
+ const accumulatedChunks: T[] = [];
748
+ let lastError: any;
749
+
750
+ try {
751
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
752
+ const key = provider ? this.getKeyByProvider(provider) : this.getKey();
753
+
754
+ if (!key) {
755
+ if (this.fallbackFn) {
756
+ this.emit('fallback', 'all keys exhausted (stream)');
757
+ yield this.fallbackFn();
758
+ return;
759
+ }
760
+ throw new AllKeysExhaustedError();
761
+ }
762
+
763
+ let iterator: AsyncGenerator<T, any, unknown>;
764
+ try {
765
+ // Start the generator
766
+ iterator = fn(key);
767
+
768
+ // PRIME THE ITERATOR:
769
+ // Try to get the first chunk. This forces the generator body to
770
+ // execute until the first 'yield' or until it throws.
771
+ const firstResult = await iterator.next();
772
+
773
+ // If we got here, the INITIAL connection/logic succeeded!
774
+ if (firstResult.done) return;
775
+
776
+ // Yield first chunk
777
+ yield firstResult.value;
778
+ if (this.semanticCache && prompt) accumulatedChunks.push(firstResult.value);
779
+
780
+ // Yield rest of chunks
781
+ for await (const chunk of iterator) {
782
+ yield chunk;
783
+ if (this.semanticCache && prompt) accumulatedChunks.push(chunk);
784
+ }
785
+
786
+ // Success! Store in cache
787
+ if (this.semanticCache && prompt && currentPromptVector && accumulatedChunks.length > 0) {
788
+ this.semanticCache.set(prompt, currentPromptVector, accumulatedChunks);
789
+ }
790
+
791
+ return; // Full success, exit retry loop
792
+
793
+ } catch (error: any) {
794
+ lastError = error;
795
+ const classification = this.classifyError(error, finishReason);
796
+
797
+ this.markFailed(key, classification);
798
+ this.emit('executeFailed', key, error);
799
+
800
+ // Note: If we already yielded the FIRST chunk, we CANNOT retry the connection
801
+ // because the user has already received data. Mid-stream failures propagate.
802
+ if (accumulatedChunks.length > 0) {
803
+ throw error;
804
+ }
805
+
806
+ if (!classification.retryable || attempt >= maxRetries) {
807
+ if (this.fallbackFn && attempt >= maxRetries) {
808
+ this.emit('fallback', 'max retries exceeded (stream)');
809
+ yield this.fallbackFn();
810
+ return;
811
+ }
812
+ throw error;
813
+ }
814
+
815
+ const delay = this.calculateBackoff(attempt);
816
+ this.emit('retry', key, attempt + 1, delay);
817
+ await this._sleep(delay);
818
+ }
819
+ }
820
+ } finally {
821
+ this.activeCalls--;
822
+ }
823
+
824
+ throw lastError;
825
+ }
826
+
827
+ private async _executeWithRetry<T>(
828
+ fn: (key: string, signal?: AbortSignal) => Promise<T>,
829
+ maxRetries: number,
830
+ timeoutMs?: number,
831
+ finishReason?: string,
832
+ provider?: string
833
+ ): Promise<T> {
834
+ let lastError: any;
835
+
836
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
837
+ const key = provider ? this.getKeyByProvider(provider) : this.getKey();
838
+
839
+ if (!key) {
840
+ // All keys exhausted — try fallback
841
+ if (this.fallbackFn) {
842
+ this.emit('fallback', 'all keys exhausted');
843
+ return this.fallbackFn();
844
+ }
845
+ throw new AllKeysExhaustedError();
846
+ }
847
+
848
+ try {
849
+ const start = Date.now();
850
+ let result: T;
851
+
852
+ if (timeoutMs) {
853
+ result = await this._executeWithTimeout(fn, key, timeoutMs);
854
+ } else {
855
+ result = await fn(key);
856
+ }
857
+
858
+ const duration = Date.now() - start;
859
+ this.markSuccess(key, duration);
860
+ this.emit('executeSuccess', key, duration);
861
+ return result;
862
+
863
+ } catch (error: any) {
864
+ lastError = error;
865
+ const classification = this.classifyError(error, finishReason);
866
+
867
+ this.markFailed(key, classification);
868
+ this.emit('executeFailed', key, error);
869
+
870
+ if (!classification.retryable || attempt >= maxRetries) {
871
+ // Non-retryable or out of retries
872
+ if (this.fallbackFn && attempt >= maxRetries) {
873
+ this.emit('fallback', 'max retries exceeded');
874
+ return this.fallbackFn();
875
+ }
876
+ throw error;
877
+ }
878
+
879
+ // Retry with backoff
880
+ const delay = this.calculateBackoff(attempt);
881
+ this.emit('retry', key, attempt + 1, delay);
882
+ await this._sleep(delay);
883
+ }
884
+ }
885
+
886
+ // Should not reach here, but safety net
887
+ throw lastError;
888
+ }
889
+
890
+ private async _executeWithTimeout<T>(
891
+ fn: (key: string, signal?: AbortSignal) => Promise<T>,
892
+ key: string,
893
+ timeoutMs: number
894
+ ): Promise<T> {
895
+ const controller = new AbortController();
896
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
897
+
898
+ try {
899
+ const result = await Promise.race([
900
+ fn(key, controller.signal),
901
+ new Promise<never>((_, reject) => {
902
+ controller.signal.addEventListener('abort', () => {
903
+ reject(new TimeoutError(timeoutMs));
904
+ });
905
+ })
906
+ ]);
907
+ return result;
908
+ } finally {
909
+ clearTimeout(timer);
910
+ }
911
+ }
912
+
913
+ private _sleep(ms: number): Promise<void> {
914
+ return new Promise(resolve => {
915
+ const timer = setTimeout(resolve, ms);
916
+ if (timer.unref) timer.unref();
917
+ });
918
+ }
919
+
920
+ // ─── Health Checks ───────────────────────────────────────────────────────
921
+
922
+ /**
923
+ * Set a health check function that tests if a key is operational
924
+ */
925
+ public setHealthCheck(fn: (key: string) => Promise<boolean>) {
926
+ this.healthCheckFn = fn;
927
+ }
928
+
929
+ /**
930
+ * Start periodic health checks
931
+ * @param intervalMs How often to run health checks (default: 60s)
932
+ */
933
+ public startHealthChecks(intervalMs: number = 60_000) {
934
+ this.stopHealthChecks(); // Clear any existing interval
935
+ this.healthCheckInterval = setInterval(() => this._runHealthChecks(), intervalMs);
936
+ if (this.healthCheckInterval.unref) this.healthCheckInterval.unref();
937
+ }
938
+
939
+ /**
940
+ * Stop periodic health checks
941
+ */
942
+ public stopHealthChecks() {
943
+ if (this.healthCheckInterval) {
944
+ clearInterval(this.healthCheckInterval);
945
+ this.healthCheckInterval = undefined;
946
+ }
947
+ // Flush any pending state to disk on shutdown
948
+ this._flushState();
949
+ }
950
+
951
+ private async _runHealthChecks() {
952
+ if (!this.healthCheckFn) return;
953
+
954
+ // Check non-DEAD keys that are in OPEN or HALF_OPEN state
955
+ const keysToCheck = this.keys.filter(k =>
956
+ k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN'
957
+ );
958
+
959
+ for (const k of keysToCheck) {
960
+ try {
961
+ const healthy = await this.healthCheckFn(k.key);
962
+ if (healthy) {
963
+ this.markSuccess(k.key);
964
+ this.emit('healthCheckPassed', k.key);
965
+ } else {
966
+ this.emit('healthCheckFailed', k.key, new Error('Health check returned false'));
967
+ }
968
+ } catch (error) {
969
+ this.emit('healthCheckFailed', k.key, error);
970
+ }
971
+ }
972
+ }
973
+
974
+ // ─── Persistence ─────────────────────────────────────────────────────────
975
+
976
+ /**
977
+ * Debounced save — marks state as dirty and flushes after 500ms of inactivity.
978
+ * Under heavy load (multiple getKey/markSuccess/markFailed calls), this coalesces
979
+ * dozens of writeFileSync calls into one.
980
+ */
981
+ private saveState() {
982
+ if (!this.storage) return;
983
+ this._saveDirty = true;
984
+
985
+ if (!this._saveTimer) {
986
+ this._saveTimer = setTimeout(() => {
987
+ this._flushState();
988
+ this._saveTimer = undefined;
989
+ }, 500);
990
+ if (this._saveTimer.unref) this._saveTimer.unref();
991
+ }
992
+ }
993
+
994
+ /**
995
+ * Immediately flush state to storage. Called by the debounce timer
996
+ * and by stopHealthChecks() to ensure clean shutdown.
997
+ */
998
+ public flushState() {
999
+ this._flushState();
1000
+ }
1001
+
1002
+ private _flushState() {
1003
+ if (!this._saveDirty || !this.storage) return;
1004
+ this._saveDirty = false;
1005
+
1006
+ if (this._saveTimer) {
1007
+ clearTimeout(this._saveTimer);
1008
+ this._saveTimer = undefined;
1009
+ }
1010
+
1011
+ const state = this.keys.reduce((acc, k) => ({
1012
+ ...acc,
1013
+ [k.key]: {
1014
+ failCount: k.failCount,
1015
+ failedAt: k.failedAt,
1016
+ isQuotaError: k.isQuotaError,
1017
+ circuitState: k.circuitState,
1018
+ lastUsed: k.lastUsed,
1019
+ successCount: k.successCount,
1020
+ totalRequests: k.totalRequests,
1021
+ customCooldown: k.customCooldown,
1022
+ weight: k.weight,
1023
+ averageLatency: k.averageLatency,
1024
+ totalLatency: k.totalLatency,
1025
+ latencySamples: k.latencySamples,
1026
+ provider: k.provider
1027
+ }
1028
+ }), {});
1029
+ this.storage.setItem(this.storageKey, JSON.stringify(state));
1030
+ }
1031
+
1032
+ private loadState() {
1033
+ if (!this.storage) return;
1034
+ try {
1035
+ const raw = this.storage.getItem(this.storageKey);
1036
+ if (!raw) return;
1037
+ const data = JSON.parse(raw);
1038
+ const now = Date.now();
1039
+
1040
+ this.keys.forEach(k => {
1041
+ if (!data[k.key]) return;
1042
+ Object.assign(k, data[k.key]);
1043
+
1044
+ // Resurrect DEAD keys that have exceeded the TTL.
1045
+ // This allows keys that were marked dead (e.g., temporary 403)
1046
+ // to be retested after a cooldown period instead of staying dead forever.
1047
+ if (k.circuitState === 'DEAD' && k.failedAt) {
1048
+ const deadDuration = now - k.failedAt;
1049
+ if (deadDuration >= CONFIG.DEAD_KEY_TTL) {
1050
+ k.circuitState = 'HALF_OPEN';
1051
+ k.halfOpenTestTime = null; // Allow immediate test
1052
+ k.failCount = 0;
1053
+ }
1054
+ }
1055
+
1056
+ // Clear stale cooldowns from previous sessions.
1057
+ // If a key was cooling down and the process restarted after the
1058
+ // cooldown would have expired, reset it to CLOSED.
1059
+ if (k.circuitState === 'OPEN' && k.failedAt) {
1060
+ const cooldown = k.customCooldown || (k.isQuotaError ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT);
1061
+ if (now - k.failedAt >= cooldown) {
1062
+ k.circuitState = 'HALF_OPEN';
1063
+ k.halfOpenTestTime = null;
1064
+ }
1065
+ }
1066
+ });
1067
+ } catch (e) {
1068
+ console.error("Failed to load key state");
1069
+ }
1070
+ }
1071
+ }