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