@splashcodex/api-key-manager 5.0.2 → 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/bin/cli.js +154 -24
- package/dist/env/index.d.ts +6 -0
- package/dist/env/index.js +12 -0
- package/dist/env/index.js.map +1 -0
- package/dist/env/loader.d.ts +95 -0
- package/dist/env/loader.js +172 -0
- package/dist/env/loader.js.map +1 -0
- package/dist/index.d.ts +43 -13
- package/dist/index.js +167 -33
- 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/anthropic.d.ts +54 -0
- package/dist/presets/anthropic.js +71 -0
- package/dist/presets/anthropic.js.map +1 -0
- package/dist/presets/base.d.ts +6 -0
- package/dist/presets/base.js +62 -7
- package/dist/presets/base.js.map +1 -1
- package/dist/presets/index.d.ts +1 -0
- package/dist/presets/index.js +3 -1
- package/dist/presets/index.js.map +1 -1
- package/dist/presets/multi.js +12 -3
- package/dist/presets/multi.js.map +1 -1
- package/package.json +37 -5
- package/src/env/index.ts +14 -0
- package/src/env/loader.ts +225 -0
- package/src/index.ts +195 -45
- package/src/persistence/file.ts +9 -2
- package/src/presets/anthropic.ts +78 -0
- package/src/presets/base.ts +71 -11
- package/src/presets/index.ts +1 -0
- package/src/presets/multi.ts +14 -4
package/src/index.ts
CHANGED
|
@@ -6,9 +6,19 @@
|
|
|
6
6
|
* NEW in v5.0: Provider Presets (GeminiManager, OpenAIManager, MultiManager),
|
|
7
7
|
* Built-in Persistence (FileStorage, MemoryStorage)
|
|
8
8
|
* Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
|
|
9
|
+
* Infrastructure: cockatiel (Bulkhead queueing + ExponentialBackoff w/ decorrelated jitter)
|
|
9
10
|
*/
|
|
10
11
|
|
|
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';
|
|
12
22
|
|
|
13
23
|
// ─── Re-exports: Persistence ─────────────────────────────────────────────────
|
|
14
24
|
// Persistence adapters can be imported from root or via subpath
|
|
@@ -80,17 +90,30 @@ export interface ExecuteOptions {
|
|
|
80
90
|
provider?: string; // Filter keys by provider (e.g. 'openai')
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
export
|
|
84
|
-
storage
|
|
85
|
-
strategy
|
|
86
|
-
fallbackFn
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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>;
|
|
94
117
|
|
|
95
118
|
export interface CacheEntry {
|
|
96
119
|
vector: number[];
|
|
@@ -126,6 +149,7 @@ const CONFIG = {
|
|
|
126
149
|
HALF_OPEN_TEST_DELAY: 60 * 1000, // 1 minute after open
|
|
127
150
|
MAX_BACKOFF: 64 * 1000, // 64 seconds max
|
|
128
151
|
BASE_BACKOFF: 1000, // 1 second base
|
|
152
|
+
DEAD_KEY_TTL: 60 * 60 * 1000, // 1 hour — DEAD keys get retested after this
|
|
129
153
|
};
|
|
130
154
|
|
|
131
155
|
// Error classification patterns
|
|
@@ -298,14 +322,18 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
298
322
|
private strategy: LoadBalancingStrategy;
|
|
299
323
|
private fallbackFn?: () => any;
|
|
300
324
|
|
|
301
|
-
// Bulkhead state
|
|
302
|
-
|
|
303
|
-
private
|
|
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;
|
|
304
328
|
|
|
305
329
|
// Health check state
|
|
306
330
|
private healthCheckFn?: (key: string) => Promise<boolean>;
|
|
307
331
|
private healthCheckInterval?: ReturnType<typeof setInterval>;
|
|
308
332
|
|
|
333
|
+
// Debounced persistence — avoids writeFileSync on every single call
|
|
334
|
+
private _saveTimer?: ReturnType<typeof setTimeout>;
|
|
335
|
+
private _saveDirty: boolean = false;
|
|
336
|
+
|
|
309
337
|
// Semantic Cache v4
|
|
310
338
|
private semanticCache?: SemanticCache;
|
|
311
339
|
private getEmbeddingFn?: (text: string) => Promise<number[]>;
|
|
@@ -329,7 +357,7 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
329
357
|
|
|
330
358
|
// Detect if second arg is options object or legacy storage
|
|
331
359
|
let options: ApiKeyManagerOptions = {};
|
|
332
|
-
if (storageOrOptions && typeof storageOrOptions === 'object' && ('storage' in storageOrOptions || 'strategy' in storageOrOptions || 'fallbackFn' in storageOrOptions || 'concurrency' in storageOrOptions || 'semanticCache' in storageOrOptions)) {
|
|
360
|
+
if (storageOrOptions && typeof storageOrOptions === 'object' && ('storage' in storageOrOptions || 'strategy' in storageOrOptions || 'fallbackFn' in storageOrOptions || 'concurrency' in storageOrOptions || 'concurrencyQueueSize' in storageOrOptions || 'semanticCache' in storageOrOptions)) {
|
|
333
361
|
// New v3 options object
|
|
334
362
|
options = storageOrOptions as ApiKeyManagerOptions;
|
|
335
363
|
} else {
|
|
@@ -340,13 +368,27 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
340
368
|
};
|
|
341
369
|
}
|
|
342
370
|
|
|
371
|
+
// Validate options with Zod (will throw meaningful errors on invalid configs)
|
|
372
|
+
options = ApiKeyManagerOptionsSchema.parse(options);
|
|
373
|
+
|
|
343
374
|
this.storage = options.storage || {
|
|
344
375
|
getItem: () => null,
|
|
345
376
|
setItem: () => { },
|
|
346
377
|
};
|
|
347
378
|
this.strategy = options.strategy || new StandardStrategy();
|
|
348
379
|
this.fallbackFn = options.fallbackFn;
|
|
349
|
-
|
|
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
|
+
}
|
|
350
392
|
|
|
351
393
|
// Init Semantic Cache if provided
|
|
352
394
|
if (options.semanticCache) {
|
|
@@ -603,11 +645,31 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
603
645
|
|
|
604
646
|
// ─── Backoff ─────────────────────────────────────────────────────────────
|
|
605
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
|
+
|
|
606
662
|
public calculateBackoff(attempt: number): number {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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;
|
|
611
673
|
}
|
|
612
674
|
|
|
613
675
|
// ─── Stats ───────────────────────────────────────────────────────────────
|
|
@@ -664,25 +726,24 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
664
726
|
}
|
|
665
727
|
}
|
|
666
728
|
|
|
667
|
-
// 2. Bulkhead check
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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;
|
|
680
742
|
}
|
|
681
|
-
|
|
682
|
-
return result;
|
|
683
|
-
} finally {
|
|
684
|
-
this.activeCalls--;
|
|
685
743
|
}
|
|
744
|
+
|
|
745
|
+
// No concurrency limit configured — run directly
|
|
746
|
+
return this._executeWithSemanticAndRetry<T>(fn, maxRetries, timeoutMs, finishReason, provider, prompt, currentPromptVector);
|
|
686
747
|
}
|
|
687
748
|
|
|
688
749
|
/**
|
|
@@ -732,13 +793,18 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
732
793
|
}
|
|
733
794
|
}
|
|
734
795
|
|
|
735
|
-
// 2. Bulkhead check
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
+
}
|
|
739
807
|
}
|
|
740
|
-
|
|
741
|
-
this.activeCalls++;
|
|
742
808
|
const accumulatedChunks: T[] = [];
|
|
743
809
|
let lastError: any;
|
|
744
810
|
|
|
@@ -813,12 +879,35 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
813
879
|
}
|
|
814
880
|
}
|
|
815
881
|
} finally {
|
|
816
|
-
|
|
882
|
+
// Slot is freed automatically when the generator completes/throws
|
|
817
883
|
}
|
|
818
884
|
|
|
819
885
|
throw lastError;
|
|
820
886
|
}
|
|
821
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
|
+
|
|
822
911
|
private async _executeWithRetry<T>(
|
|
823
912
|
fn: (key: string, signal?: AbortSignal) => Promise<T>,
|
|
824
913
|
maxRetries: number,
|
|
@@ -939,6 +1028,8 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
939
1028
|
clearInterval(this.healthCheckInterval);
|
|
940
1029
|
this.healthCheckInterval = undefined;
|
|
941
1030
|
}
|
|
1031
|
+
// Flush any pending state to disk on shutdown
|
|
1032
|
+
this._flushState();
|
|
942
1033
|
}
|
|
943
1034
|
|
|
944
1035
|
private async _runHealthChecks() {
|
|
@@ -966,8 +1057,41 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
966
1057
|
|
|
967
1058
|
// ─── Persistence ─────────────────────────────────────────────────────────
|
|
968
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
|
+
*/
|
|
969
1065
|
private saveState() {
|
|
970
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
|
+
|
|
971
1095
|
const state = this.keys.reduce((acc, k) => ({
|
|
972
1096
|
...acc,
|
|
973
1097
|
[k.key]: {
|
|
@@ -995,8 +1119,34 @@ export class ApiKeyManager extends EventEmitter {
|
|
|
995
1119
|
const raw = this.storage.getItem(this.storageKey);
|
|
996
1120
|
if (!raw) return;
|
|
997
1121
|
const data = JSON.parse(raw);
|
|
1122
|
+
const now = Date.now();
|
|
1123
|
+
|
|
998
1124
|
this.keys.forEach(k => {
|
|
999
|
-
if (data[k.key])
|
|
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
|
+
}
|
|
1000
1150
|
});
|
|
1001
1151
|
} catch (e) {
|
|
1002
1152
|
console.error("Failed to load key state");
|
package/src/persistence/file.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* @module persistence/file
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
|
|
13
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync, renameSync } from 'fs';
|
|
14
14
|
import { dirname, join } from 'path';
|
|
15
15
|
import { tmpdir } from 'os';
|
|
16
16
|
|
|
@@ -57,13 +57,20 @@ export class FileStorage {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
setItem(_key: string, value: string): void {
|
|
60
|
+
const tmpPath = this.filePath + '.tmp';
|
|
60
61
|
try {
|
|
61
62
|
const dir = dirname(this.filePath);
|
|
62
63
|
if (!existsSync(dir)) {
|
|
63
64
|
mkdirSync(dir, { recursive: true });
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
+
// Write to a temp file first, then atomically rename to the target.
|
|
67
|
+
// renameSync() is a single syscall — the file is never in a
|
|
68
|
+
// half-written state if the process crashes mid-write.
|
|
69
|
+
writeFileSync(tmpPath, value, 'utf-8');
|
|
70
|
+
renameSync(tmpPath, this.filePath);
|
|
66
71
|
} catch {
|
|
72
|
+
// Clean up temp file if rename failed
|
|
73
|
+
try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* silent */ }
|
|
67
74
|
// Silently fail — state will be lost on restart
|
|
68
75
|
}
|
|
69
76
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Preset — One-line Claude API key management
|
|
3
|
+
*
|
|
4
|
+
* Pre-configured singleton with:
|
|
5
|
+
* - Reads `ANTHROPIC_API_KEY` from env
|
|
6
|
+
* - LatencyStrategy for optimal key selection
|
|
7
|
+
* - File persistence (survives restarts)
|
|
8
|
+
* - Health checks every 5 minutes
|
|
9
|
+
* - Concurrency limit of 20
|
|
10
|
+
*
|
|
11
|
+
* @module presets/anthropic
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { AnthropicManager } from '@splashcodex/api-key-manager/presets/anthropic';
|
|
16
|
+
*
|
|
17
|
+
* const result = AnthropicManager.getInstance();
|
|
18
|
+
* if (!result.success) throw result.error;
|
|
19
|
+
* const claude = result.data;
|
|
20
|
+
*
|
|
21
|
+
* const response = await claude.execute(async (key) => {
|
|
22
|
+
* const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
23
|
+
* method: 'POST',
|
|
24
|
+
* headers: {
|
|
25
|
+
* 'x-api-key': key,
|
|
26
|
+
* 'anthropic-version': '2023-06-01',
|
|
27
|
+
* 'content-type': 'application/json',
|
|
28
|
+
* },
|
|
29
|
+
* body: JSON.stringify({
|
|
30
|
+
* model: 'claude-sonnet-4-20250514',
|
|
31
|
+
* max_tokens: 1024,
|
|
32
|
+
* messages: [{ role: 'user', content: 'Hello!' }],
|
|
33
|
+
* }),
|
|
34
|
+
* });
|
|
35
|
+
* const data = await res.json();
|
|
36
|
+
* return data.content[0].text;
|
|
37
|
+
* }, { maxRetries: 3, timeoutMs: 60000 });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { BasePreset, PresetOptions, Result } from './base';
|
|
42
|
+
|
|
43
|
+
export class AnthropicManager extends BasePreset {
|
|
44
|
+
private static readonly PROVIDER = 'anthropic';
|
|
45
|
+
|
|
46
|
+
private constructor(keys: string[], options: PresetOptions) {
|
|
47
|
+
super(keys, options);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private static getDefaultOptions(): Partial<PresetOptions> {
|
|
51
|
+
return {
|
|
52
|
+
envKeys: ['ANTHROPIC_API_KEY'],
|
|
53
|
+
provider: AnthropicManager.PROVIDER,
|
|
54
|
+
concurrency: 20,
|
|
55
|
+
healthCheckIntervalMs: 300_000,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get or create the singleton AnthropicManager instance.
|
|
61
|
+
*
|
|
62
|
+
* @param overrides - Optional configuration overrides
|
|
63
|
+
* @returns Result<AnthropicManager>
|
|
64
|
+
*/
|
|
65
|
+
static getInstance(overrides?: Partial<PresetOptions>): Result<AnthropicManager> {
|
|
66
|
+
return BasePreset.createInstance(
|
|
67
|
+
AnthropicManager as unknown as new (keys: string[], options: PresetOptions) => AnthropicManager,
|
|
68
|
+
AnthropicManager.getDefaultOptions(),
|
|
69
|
+
overrides
|
|
70
|
+
) as Result<AnthropicManager>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static reset(): void {
|
|
74
|
+
BasePreset.resetInstance(AnthropicManager.PROVIDER);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { Result } from './base';
|
package/src/presets/base.ts
CHANGED
|
@@ -20,8 +20,10 @@ import {
|
|
|
20
20
|
LoadBalancingStrategy,
|
|
21
21
|
} from '../index';
|
|
22
22
|
import { FileStorage } from '../persistence/file';
|
|
23
|
-
import { join } from 'path';
|
|
24
|
-
import { tmpdir } from 'os';
|
|
23
|
+
import { join, basename } from 'path';
|
|
24
|
+
import { tmpdir, homedir } from 'os';
|
|
25
|
+
import { readFileSync, existsSync } from 'fs';
|
|
26
|
+
import { createHash } from 'crypto';
|
|
25
27
|
|
|
26
28
|
// ─── Result Type ─────────────────────────────────────────────────────────────
|
|
27
29
|
|
|
@@ -64,6 +66,24 @@ export interface PresetLogger {
|
|
|
64
66
|
error(message: string, ...args: any[]): void;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// ─── Project Identifier ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Derive a short, stable identifier for the current project.
|
|
73
|
+
* Uses the directory name + a hash of the full CWD to avoid collisions
|
|
74
|
+
* when multiple projects run simultaneously.
|
|
75
|
+
*
|
|
76
|
+
* Examples:
|
|
77
|
+
* /home/codedex/projects/WhatsDeX → "whatsdex_a3f2"
|
|
78
|
+
* /home/codedex/projects/DeXdo → "dexdo_b7c1"
|
|
79
|
+
*/
|
|
80
|
+
function getProjectId(): string {
|
|
81
|
+
const cwd = process.cwd();
|
|
82
|
+
const dirName = basename(cwd).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
83
|
+
const hash = createHash('md5').update(cwd).digest('hex').slice(0, 4);
|
|
84
|
+
return `${dirName}_${hash}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
67
87
|
// ─── Base Preset Class ──────────────────────────────────────────────────────
|
|
68
88
|
|
|
69
89
|
/**
|
|
@@ -101,12 +121,13 @@ export abstract class BasePreset {
|
|
|
101
121
|
...options,
|
|
102
122
|
};
|
|
103
123
|
|
|
124
|
+
const projectId = getProjectId();
|
|
104
125
|
const stateFile = this.options.stateFilePath ||
|
|
105
|
-
join(tmpdir(), `codedex_${this.options.provider}_state.json`);
|
|
126
|
+
join(tmpdir(), `codedex_${this.options.provider}_${projectId}_state.json`);
|
|
106
127
|
|
|
107
128
|
const storage = new FileStorage({
|
|
108
129
|
filePath: stateFile,
|
|
109
|
-
clearOnInit:
|
|
130
|
+
clearOnInit: false, // Preserve circuit breaker state across restarts
|
|
110
131
|
});
|
|
111
132
|
|
|
112
133
|
this.manager = new ApiKeyManager(apiKeys, {
|
|
@@ -253,6 +274,40 @@ export abstract class BasePreset {
|
|
|
253
274
|
return [...new Set(keys)]; // Deduplicate
|
|
254
275
|
}
|
|
255
276
|
|
|
277
|
+
protected static getConfigPath(): string {
|
|
278
|
+
return join(homedir(), '.codedex', 'api_keys.json');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Parse API keys from ~/.codedex/api_keys.json
|
|
283
|
+
* Format should be: { "ENV_VAR_NAME": "key1,key2" } or { "ENV_VAR_NAME": ["key1", "key2"] }
|
|
284
|
+
*/
|
|
285
|
+
protected static parseKeysFromHomeDir(envKeys: string[]): string[] {
|
|
286
|
+
const configPath = this.getConfigPath();
|
|
287
|
+
|
|
288
|
+
if (!existsSync(configPath)) return [];
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const fileContent = readFileSync(configPath, 'utf-8');
|
|
292
|
+
const parsed = JSON.parse(fileContent);
|
|
293
|
+
const keys: string[] = [];
|
|
294
|
+
|
|
295
|
+
for (const envName of envKeys) {
|
|
296
|
+
const value = parsed[envName];
|
|
297
|
+
if (!value) continue;
|
|
298
|
+
|
|
299
|
+
if (Array.isArray(value)) {
|
|
300
|
+
keys.push(...value.filter((k: any) => typeof k === 'string' && k.trim()));
|
|
301
|
+
} else if (typeof value === 'string' && value.trim()) {
|
|
302
|
+
keys.push(...value.split(',').map(k => k.trim()).filter(k => k.length > 0));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return [...new Set(keys)];
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
256
311
|
/**
|
|
257
312
|
* Get or create the singleton instance for a preset.
|
|
258
313
|
* This is the core factory used by all preset subclasses.
|
|
@@ -277,15 +332,20 @@ export abstract class BasePreset {
|
|
|
277
332
|
|
|
278
333
|
// Parse keys
|
|
279
334
|
const envKeys = mergedOptions.envKeys || [];
|
|
280
|
-
|
|
335
|
+
let keys = BasePreset.parseKeysFromEnv(envKeys);
|
|
281
336
|
|
|
282
337
|
if (keys.length === 0) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
338
|
+
keys = BasePreset.parseKeysFromHomeDir(envKeys);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (keys.length === 0) {
|
|
342
|
+
return {
|
|
343
|
+
success: false,
|
|
344
|
+
error: new Error(
|
|
345
|
+
`[${instanceKey}] No API keys found in env vars: ${envKeys.join(', ')}. ` +
|
|
346
|
+
`Set these environment variables or use loadCentralEnv() to load from ~/codedex/env/ first.`
|
|
347
|
+
),
|
|
348
|
+
};
|
|
289
349
|
}
|
|
290
350
|
|
|
291
351
|
try {
|
package/src/presets/index.ts
CHANGED
|
@@ -6,5 +6,6 @@ export { BasePreset } from './base';
|
|
|
6
6
|
export type { PresetOptions, PresetLogger, Result } from './base';
|
|
7
7
|
export { GeminiManager } from './gemini';
|
|
8
8
|
export { OpenAIManager } from './openai';
|
|
9
|
+
export { AnthropicManager } from './anthropic';
|
|
9
10
|
export { MultiManager } from './multi';
|
|
10
11
|
export type { ProviderConfig, MultiManagerOptions } from './multi';
|
package/src/presets/multi.ts
CHANGED
|
@@ -42,8 +42,16 @@ import {
|
|
|
42
42
|
ApiKeyManagerOptions,
|
|
43
43
|
} from '../index';
|
|
44
44
|
import { FileStorage } from '../persistence/file';
|
|
45
|
-
import { join } from 'path';
|
|
45
|
+
import { join, basename } from 'path';
|
|
46
46
|
import { tmpdir } from 'os';
|
|
47
|
+
import { createHash } from 'crypto';
|
|
48
|
+
|
|
49
|
+
function getProjectId(): string {
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
const dirName = basename(cwd).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
52
|
+
const hash = createHash('md5').update(cwd).digest('hex').slice(0, 4);
|
|
53
|
+
return `${dirName}_${hash}`;
|
|
54
|
+
}
|
|
47
55
|
|
|
48
56
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
49
57
|
|
|
@@ -90,12 +98,14 @@ export class MultiManager {
|
|
|
90
98
|
const keys = MultiManager.parseKeysFromEnv(config.envKeys);
|
|
91
99
|
|
|
92
100
|
if (keys.length === 0) {
|
|
93
|
-
this.logger.warn(`[MultiManager:${providerName}] No API keys found in: ${config.envKeys.join(', ')}`);
|
|
101
|
+
this.logger.warn(`[MultiManager:${providerName}] No API keys found in: ${config.envKeys.join(', ')} — provider skipped`);
|
|
102
|
+
continue;
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
const projectId = getProjectId();
|
|
96
106
|
const storage = new FileStorage({
|
|
97
|
-
filePath: join(tmpdir(), `codedex_multi_${providerName}_state.json`),
|
|
98
|
-
clearOnInit:
|
|
107
|
+
filePath: join(tmpdir(), `codedex_multi_${providerName}_${projectId}_state.json`),
|
|
108
|
+
clearOnInit: false, // Preserve circuit breaker state across restarts
|
|
99
109
|
});
|
|
100
110
|
|
|
101
111
|
const manager = new ApiKeyManager(keys, {
|