@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/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 interface ApiKeyManagerOptions {
84
- storage?: any;
85
- strategy?: LoadBalancingStrategy;
86
- fallbackFn?: () => any;
87
- concurrency?: number; // Max concurrent execute() calls
88
- semanticCache?: {
89
- threshold?: number; // Similarity threshold (0.0 - 1.0, default 0.95)
90
- ttlMs?: number; // Cache TTL
91
- getEmbedding: (text: string) => Promise<number[]>;
92
- };
93
- }
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
- private maxConcurrency: number;
303
- private activeCalls: number = 0;
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
- this.maxConcurrency = options.concurrency || Infinity;
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
- const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
608
- const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
609
- const jitter = Math.random() * 1000;
610
- return capped + jitter;
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
- if (this.activeCalls >= this.maxConcurrency) {
669
- this.emit('bulkheadRejected');
670
- throw new BulkheadRejectionError();
671
- }
672
-
673
- this.activeCalls++;
674
- try {
675
- const result = await this._executeWithRetry(fn, maxRetries, timeoutMs, finishReason, provider);
676
-
677
- // 3. Store in Semantic Cache on success
678
- if (this.semanticCache && prompt && currentPromptVector) {
679
- this.semanticCache.set(prompt, currentPromptVector, result);
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
- if (this.activeCalls >= this.maxConcurrency) {
737
- this.emit('bulkheadRejected');
738
- throw new BulkheadRejectionError();
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
- this.activeCalls--;
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]) Object.assign(k, 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");
@@ -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
- writeFileSync(this.filePath, value, 'utf-8');
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';
@@ -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: true, // Fresh start each session to clear stale DEAD keys
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
- const keys = BasePreset.parseKeysFromEnv(envKeys);
335
+ let keys = BasePreset.parseKeysFromEnv(envKeys);
281
336
 
282
337
  if (keys.length === 0) {
283
- const logger = mergedOptions.logger || console;
284
- logger.warn(
285
- `[${instanceKey}] No API keys found in env vars: ${envKeys.join(', ')}. ` +
286
- `AI features will be disabled.`
287
- );
288
- // Still create the instance with empty keys — allows graceful degradation
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 {
@@ -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';
@@ -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: true,
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, {