@splashcodex/api-key-manager 4.1.0 → 5.0.2

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.
Files changed (39) hide show
  1. package/README.md +129 -201
  2. package/bin/cli.js +50 -0
  3. package/dist/index.d.ts +7 -2
  4. package/dist/index.js +16 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/persistence/file.d.ts +43 -0
  7. package/dist/persistence/file.js +82 -0
  8. package/dist/persistence/file.js.map +1 -0
  9. package/dist/persistence/index.d.ts +7 -0
  10. package/dist/persistence/index.js +12 -0
  11. package/dist/persistence/index.js.map +1 -0
  12. package/dist/persistence/memory.d.ts +30 -0
  13. package/dist/persistence/memory.js +43 -0
  14. package/dist/persistence/memory.js.map +1 -0
  15. package/dist/presets/base.d.ts +138 -0
  16. package/dist/presets/base.js +227 -0
  17. package/dist/presets/base.js.map +1 -0
  18. package/dist/presets/gemini.d.ts +60 -0
  19. package/dist/presets/gemini.js +77 -0
  20. package/dist/presets/gemini.js.map +1 -0
  21. package/dist/presets/index.d.ts +10 -0
  22. package/dist/presets/index.js +16 -0
  23. package/dist/presets/index.js.map +1 -0
  24. package/dist/presets/multi.d.ts +120 -0
  25. package/dist/presets/multi.js +210 -0
  26. package/dist/presets/multi.js.map +1 -0
  27. package/dist/presets/openai.d.ts +45 -0
  28. package/dist/presets/openai.js +62 -0
  29. package/dist/presets/openai.js.map +1 -0
  30. package/package.json +88 -5
  31. package/src/index.ts +24 -3
  32. package/src/persistence/file.ts +89 -0
  33. package/src/persistence/index.ts +7 -0
  34. package/src/persistence/memory.ts +43 -0
  35. package/src/presets/base.ts +323 -0
  36. package/src/presets/gemini.ts +84 -0
  37. package/src/presets/index.ts +10 -0
  38. package/src/presets/multi.ts +280 -0
  39. package/src/presets/openai.ts +69 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * BasePreset — Shared foundation for all provider presets
3
+ *
4
+ * Provides the "batteries included" pattern:
5
+ * - Singleton lifecycle management
6
+ * - Environment variable parsing (single key or JSON array)
7
+ * - File-based persistence (survives restarts)
8
+ * - Event-to-logger wiring
9
+ * - Health check scheduling
10
+ * - Result<T> pattern for safe initialization
11
+ *
12
+ * @module presets/base
13
+ */
14
+
15
+ import {
16
+ ApiKeyManager,
17
+ LatencyStrategy,
18
+ ExecuteOptions,
19
+ ApiKeyManagerOptions,
20
+ LoadBalancingStrategy,
21
+ } from '../index';
22
+ import { FileStorage } from '../persistence/file';
23
+ import { join } from 'path';
24
+ import { tmpdir } from 'os';
25
+
26
+ // ─── Result Type ─────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * A discriminated union for safe error handling without exceptions.
30
+ * Used by `getInstance()` to avoid crashing on missing env vars.
31
+ */
32
+ export type Result<T> =
33
+ | { success: true; data: T }
34
+ | { success: false; error: Error };
35
+
36
+ // ─── Configuration ──────────────────────────────────────────────────────────
37
+
38
+ export interface PresetOptions {
39
+ /** Environment variable name(s) to read API keys from. */
40
+ envKeys: string[];
41
+ /** Provider name for logging and file state isolation. Default: 'default' */
42
+ provider?: string;
43
+ /** Load balancing strategy. Default: LatencyStrategy */
44
+ strategy?: LoadBalancingStrategy;
45
+ /** Max concurrent execute() calls. Default: 20 */
46
+ concurrency?: number;
47
+ /** Health check interval in ms. Set to 0 to disable. Default: 300_000 (5 min) */
48
+ healthCheckIntervalMs?: number;
49
+ /** Custom health check function. If not set, health checks are disabled. */
50
+ healthCheckFn?: (key: string) => Promise<boolean>;
51
+ /** Semantic cache config. Optional. */
52
+ semanticCache?: ApiKeyManagerOptions['semanticCache'];
53
+ /** Custom logger. Defaults to console. */
54
+ logger?: PresetLogger;
55
+ /** Fallback function when all keys are exhausted. */
56
+ fallbackFn?: () => any;
57
+ /** Custom state file path. Defaults to `os.tmpdir()/codedex_{provider}_state.json` */
58
+ stateFilePath?: string;
59
+ }
60
+
61
+ export interface PresetLogger {
62
+ info(message: string, ...args: any[]): void;
63
+ warn(message: string, ...args: any[]): void;
64
+ error(message: string, ...args: any[]): void;
65
+ }
66
+
67
+ // ─── Base Preset Class ──────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Abstract base class for provider presets.
71
+ * Subclasses only need to define `getDefaultOptions()` with provider-specific
72
+ * defaults (env var name, health check function, etc.).
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * // Creating a custom preset:
77
+ * class MyProviderPreset extends BasePreset {
78
+ * protected static getDefaultOptions(): Partial<PresetOptions> {
79
+ * return {
80
+ * envKeys: ['MY_PROVIDER_API_KEY'],
81
+ * provider: 'my-provider',
82
+ * };
83
+ * }
84
+ * }
85
+ * ```
86
+ */
87
+ export abstract class BasePreset {
88
+ private static instances: Map<string, BasePreset> = new Map();
89
+
90
+ protected manager: ApiKeyManager;
91
+ protected logger: PresetLogger;
92
+ protected options: Required<Pick<PresetOptions, 'provider' | 'concurrency' | 'healthCheckIntervalMs'>> & PresetOptions;
93
+
94
+ protected constructor(apiKeys: string[], options: PresetOptions) {
95
+ this.logger = options.logger || console;
96
+
97
+ this.options = {
98
+ provider: options.provider || 'default',
99
+ concurrency: options.concurrency ?? 20,
100
+ healthCheckIntervalMs: options.healthCheckIntervalMs ?? 300_000,
101
+ ...options,
102
+ };
103
+
104
+ const stateFile = this.options.stateFilePath ||
105
+ join(tmpdir(), `codedex_${this.options.provider}_state.json`);
106
+
107
+ const storage = new FileStorage({
108
+ filePath: stateFile,
109
+ clearOnInit: true, // Fresh start each session to clear stale DEAD keys
110
+ });
111
+
112
+ this.manager = new ApiKeyManager(apiKeys, {
113
+ storage,
114
+ strategy: this.options.strategy || new LatencyStrategy(),
115
+ fallbackFn: this.options.fallbackFn,
116
+ concurrency: this.options.concurrency,
117
+ semanticCache: this.options.semanticCache,
118
+ });
119
+
120
+ this.wireEvents();
121
+
122
+ if (this.options.healthCheckFn && this.options.healthCheckIntervalMs > 0) {
123
+ this.manager.setHealthCheck(this.options.healthCheckFn);
124
+ this.manager.startHealthChecks(this.options.healthCheckIntervalMs);
125
+ }
126
+
127
+ this.logger.info(
128
+ `[${this.options.provider}] ApiKeyManager initialized with ${apiKeys.length} keys ` +
129
+ `(Strategy: ${this.options.strategy?.constructor.name || 'LatencyStrategy'}, ` +
130
+ `Concurrency: ${this.options.concurrency}, ` +
131
+ `HealthChecks: ${this.options.healthCheckIntervalMs > 0 ? `every ${this.options.healthCheckIntervalMs / 1000}s` : 'disabled'})`
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Wire all manager events to the logger.
137
+ */
138
+ private wireEvents(): void {
139
+ const tag = this.options.provider;
140
+ this.manager.on('keyDead', (key) =>
141
+ this.logger.error(`[${tag}] Key PERMANENTLY DEAD: ...${key.slice(-4)}`));
142
+ this.manager.on('circuitOpen', (key) =>
143
+ this.logger.warn(`[${tag}] Circuit OPEN (cooldown): ...${key.slice(-4)}`));
144
+ this.manager.on('keyRecovered', (key) =>
145
+ this.logger.info(`[${tag}] Key RECOVERED: ...${key.slice(-4)}`));
146
+ this.manager.on('retry', (key, attempt, delay) =>
147
+ this.logger.info(`[${tag}] Retry with ...${key.slice(-4)} (Attempt ${attempt}, Delay ${delay}ms)`));
148
+ this.manager.on('fallback', (reason) =>
149
+ this.logger.warn(`[${tag}] Triggering FALLBACK: ${reason}`));
150
+ this.manager.on('allKeysExhausted', () =>
151
+ this.logger.error(`[${tag}] ALL KEYS EXHAUSTED! No fallback available.`));
152
+ this.manager.on('bulkheadRejected', () =>
153
+ this.logger.warn(`[${tag}] Bulkhead rejected request (concurrency limit reached)`));
154
+ this.manager.on('healthCheckPassed', (key) =>
155
+ this.logger.info(`[${tag}] Health check PASSED: ...${key.slice(-4)}`));
156
+ this.manager.on('healthCheckFailed', (key) =>
157
+ this.logger.warn(`[${tag}] Health check FAILED: ...${key.slice(-4)}`));
158
+ }
159
+
160
+ // ─── Public API ─────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Execute a function with automatic key rotation, retries, timeout, and caching.
164
+ */
165
+ async execute<T>(
166
+ fn: (key: string, signal?: AbortSignal) => Promise<T>,
167
+ options?: ExecuteOptions & { prompt?: string }
168
+ ): Promise<T> {
169
+ return this.manager.execute(fn, options);
170
+ }
171
+
172
+ /**
173
+ * Execute a streaming function with retry on initial connection failure.
174
+ */
175
+ async *executeStream<T>(
176
+ fn: (key: string, signal?: AbortSignal) => AsyncGenerator<T, any, unknown>,
177
+ options?: ExecuteOptions & { prompt?: string }
178
+ ): AsyncGenerator<T, any, unknown> {
179
+ yield* this.manager.executeStream(fn, options);
180
+ }
181
+
182
+ /**
183
+ * Get the best available API key directly (low-level).
184
+ */
185
+ getKey(): string | null {
186
+ return this.manager.getKey();
187
+ }
188
+
189
+ /**
190
+ * Get the number of non-dead keys.
191
+ */
192
+ getKeyCount(): number {
193
+ return this.manager.getKeyCount();
194
+ }
195
+
196
+ /**
197
+ * Get pool health statistics.
198
+ */
199
+ getStats() {
200
+ return this.manager.getStats();
201
+ }
202
+
203
+ /**
204
+ * Get the underlying ApiKeyManager instance for advanced use.
205
+ */
206
+ getManager(): ApiKeyManager {
207
+ return this.manager;
208
+ }
209
+
210
+ /**
211
+ * Stop health checks and clean up. Call when shutting down.
212
+ */
213
+ destroy(): void {
214
+ this.manager.stopHealthChecks();
215
+ }
216
+
217
+ // ─── Static Factory ─────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Parse API keys from environment variables.
221
+ * Supports:
222
+ * - Single key: `"AIza..."`
223
+ * - JSON array: `'["AIza...", "AIzb..."]'`
224
+ * - Comma-separated: `"AIza...,AIzb..."`
225
+ */
226
+ protected static parseKeysFromEnv(envKeys: string[]): string[] {
227
+ const keys: string[] = [];
228
+
229
+ for (const envName of envKeys) {
230
+ const envValue = process.env[envName];
231
+ if (!envValue) continue;
232
+
233
+ const trimmed = envValue.trim();
234
+ if (!trimmed) continue;
235
+
236
+ // Try JSON array
237
+ if (trimmed.startsWith('[')) {
238
+ try {
239
+ const parsed = JSON.parse(trimmed);
240
+ if (Array.isArray(parsed)) {
241
+ keys.push(...parsed.filter((k: any) => typeof k === 'string' && k.trim()));
242
+ continue;
243
+ }
244
+ } catch {
245
+ // Not valid JSON, fall through to comma split
246
+ }
247
+ }
248
+
249
+ // Comma-separated or single key
250
+ keys.push(...trimmed.split(',').map(k => k.trim()).filter(k => k.length > 0));
251
+ }
252
+
253
+ return [...new Set(keys)]; // Deduplicate
254
+ }
255
+
256
+ /**
257
+ * Get or create the singleton instance for a preset.
258
+ * This is the core factory used by all preset subclasses.
259
+ *
260
+ * @param PresetClass - The preset class to instantiate
261
+ * @param overrides - Optional overrides for preset options
262
+ * @returns Result<T> — either `{ success: true, data: instance }` or `{ success: false, error }`
263
+ */
264
+ protected static createInstance<T extends BasePreset>(
265
+ PresetClass: new (keys: string[], options: PresetOptions) => T,
266
+ defaultOptions: Partial<PresetOptions>,
267
+ overrides?: Partial<PresetOptions>
268
+ ): Result<T> {
269
+ const mergedOptions = { ...defaultOptions, ...overrides } as PresetOptions;
270
+ const instanceKey = mergedOptions.provider || PresetClass.name;
271
+
272
+ // Return existing singleton
273
+ const existing = BasePreset.instances.get(instanceKey);
274
+ if (existing) {
275
+ return { success: true, data: existing as T };
276
+ }
277
+
278
+ // Parse keys
279
+ const envKeys = mergedOptions.envKeys || [];
280
+ const keys = BasePreset.parseKeysFromEnv(envKeys);
281
+
282
+ 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
289
+ }
290
+
291
+ try {
292
+ const instance = new PresetClass(keys, mergedOptions);
293
+ BasePreset.instances.set(instanceKey, instance);
294
+ return { success: true, data: instance };
295
+ } catch (error) {
296
+ return {
297
+ success: false,
298
+ error: error instanceof Error ? error : new Error(String(error)),
299
+ };
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Reset a singleton instance (primarily for testing).
305
+ */
306
+ protected static resetInstance(provider: string): void {
307
+ const existing = BasePreset.instances.get(provider);
308
+ if (existing) {
309
+ existing.destroy();
310
+ BasePreset.instances.delete(provider);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Reset ALL singleton instances (primarily for testing).
316
+ */
317
+ static resetAll(): void {
318
+ for (const [, instance] of BasePreset.instances) {
319
+ instance.destroy();
320
+ }
321
+ BasePreset.instances.clear();
322
+ }
323
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Gemini Preset — One-line Gemini API key management
3
+ *
4
+ * Pre-configured singleton with:
5
+ * - Reads `GOOGLE_GEMINI_API_KEY` or `GEMINI_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/gemini
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { GeminiManager } from '@splashcodex/api-key-manager/presets/gemini';
16
+ *
17
+ * // Initialize once (reads keys from process.env automatically)
18
+ * const result = GeminiManager.getInstance();
19
+ * if (!result.success) throw result.error;
20
+ * const gemini = result.data;
21
+ *
22
+ * // Use the execute() wrapper for automatic key rotation + retries
23
+ * const response = await gemini.execute(async (key) => {
24
+ * const genAI = new GoogleGenerativeAI(key);
25
+ * const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
26
+ * const res = await model.generateContent('Hello, world!');
27
+ * return res.response.text();
28
+ * }, { maxRetries: 3, timeoutMs: 30000 });
29
+ * ```
30
+ */
31
+
32
+ import { BasePreset, PresetOptions, Result } from './base';
33
+
34
+ export class GeminiManager extends BasePreset {
35
+ private static readonly PROVIDER = 'gemini';
36
+
37
+ private constructor(keys: string[], options: PresetOptions) {
38
+ super(keys, options);
39
+ }
40
+
41
+ /**
42
+ * Default configuration for Gemini presets.
43
+ */
44
+ private static getDefaultOptions(): Partial<PresetOptions> {
45
+ return {
46
+ envKeys: ['GOOGLE_GEMINI_API_KEY', 'GEMINI_API_KEY'],
47
+ provider: GeminiManager.PROVIDER,
48
+ concurrency: 20,
49
+ healthCheckIntervalMs: 300_000, // 5 minutes
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Get or create the singleton GeminiManager instance.
55
+ *
56
+ * @param overrides - Optional configuration overrides
57
+ * @returns Result<GeminiManager> — safely check `.success` before using `.data`
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const result = GeminiManager.getInstance();
62
+ * if (result.success) {
63
+ * const gemini = result.data;
64
+ * const text = await gemini.execute(apiCall, { maxRetries: 3 });
65
+ * }
66
+ * ```
67
+ */
68
+ static getInstance(overrides?: Partial<PresetOptions>): Result<GeminiManager> {
69
+ return BasePreset.createInstance(
70
+ GeminiManager as unknown as new (keys: string[], options: PresetOptions) => GeminiManager,
71
+ GeminiManager.getDefaultOptions(),
72
+ overrides
73
+ ) as Result<GeminiManager>;
74
+ }
75
+
76
+ /**
77
+ * Reset the singleton instance. Primarily for testing.
78
+ */
79
+ static reset(): void {
80
+ BasePreset.resetInstance(GeminiManager.PROVIDER);
81
+ }
82
+ }
83
+
84
+ export { Result } from './base';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Presets — Batteries-included provider managers
3
+ * @module presets
4
+ */
5
+ export { BasePreset } from './base';
6
+ export type { PresetOptions, PresetLogger, Result } from './base';
7
+ export { GeminiManager } from './gemini';
8
+ export { OpenAIManager } from './openai';
9
+ export { MultiManager } from './multi';
10
+ export type { ProviderConfig, MultiManagerOptions } from './multi';
@@ -0,0 +1,280 @@
1
+ /**
2
+ * MultiManager Preset — Multi-Provider Key Vault
3
+ *
4
+ * Manages API keys across multiple providers from a single entry point.
5
+ * Aggregates keys from multiple environment variables and routes
6
+ * requests to the correct provider pool.
7
+ *
8
+ * @module presets/multi
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { MultiManager } from '@splashcodex/api-key-manager/presets/multi';
13
+ *
14
+ * const result = MultiManager.getInstance({
15
+ * providers: {
16
+ * gemini: { envKeys: ['GOOGLE_GEMINI_API_KEY'] },
17
+ * openai: { envKeys: ['OPENAI_API_KEY'] },
18
+ * }
19
+ * });
20
+ *
21
+ * if (result.success) {
22
+ * const vault = result.data;
23
+ *
24
+ * // Route to specific provider
25
+ * const text = await vault.execute(async (key) => {
26
+ * // key is from the gemini pool
27
+ * return callGemini(key, prompt);
28
+ * }, { provider: 'gemini', maxRetries: 3 });
29
+ *
30
+ * // Get stats across all providers
31
+ * const stats = vault.getMultiStats();
32
+ * }
33
+ * ```
34
+ */
35
+
36
+ import {
37
+ ApiKeyManager,
38
+ LatencyStrategy,
39
+ ExecuteOptions,
40
+ ApiKeyManagerStats,
41
+ LoadBalancingStrategy,
42
+ ApiKeyManagerOptions,
43
+ } from '../index';
44
+ import { FileStorage } from '../persistence/file';
45
+ import { join } from 'path';
46
+ import { tmpdir } from 'os';
47
+
48
+ // ─── Types ──────────────────────────────────────────────────────────────────
49
+
50
+ export type Result<T> =
51
+ | { success: true; data: T }
52
+ | { success: false; error: Error };
53
+
54
+ export interface ProviderConfig {
55
+ /** Environment variable names to read API keys from */
56
+ envKeys: string[];
57
+ /** Strategy override. Default: LatencyStrategy */
58
+ strategy?: LoadBalancingStrategy;
59
+ /** Concurrency limit. Default: 20 */
60
+ concurrency?: number;
61
+ /** Semantic cache configuration. Optional. */
62
+ semanticCache?: ApiKeyManagerOptions['semanticCache'];
63
+ }
64
+
65
+ export interface MultiManagerOptions {
66
+ /** Provider configurations keyed by name (e.g., 'gemini', 'openai') */
67
+ providers: Record<string, ProviderConfig>;
68
+ /** Health check interval in ms. Default: 300_000 (5 min). Set to 0 to disable. */
69
+ healthCheckIntervalMs?: number;
70
+ /** Custom logger. Defaults to console. */
71
+ logger?: {
72
+ info(msg: string, ...args: any[]): void;
73
+ warn(msg: string, ...args: any[]): void;
74
+ error(msg: string, ...args: any[]): void;
75
+ };
76
+ }
77
+
78
+ // ─── MultiManager ───────────────────────────────────────────────────────────
79
+
80
+ export class MultiManager {
81
+ private static instance: MultiManager | null = null;
82
+
83
+ private managers = new Map<string, ApiKeyManager>();
84
+ private logger: NonNullable<MultiManagerOptions['logger']>;
85
+
86
+ private constructor(options: MultiManagerOptions) {
87
+ this.logger = options.logger || console;
88
+
89
+ for (const [providerName, config] of Object.entries(options.providers)) {
90
+ const keys = MultiManager.parseKeysFromEnv(config.envKeys);
91
+
92
+ if (keys.length === 0) {
93
+ this.logger.warn(`[MultiManager:${providerName}] No API keys found in: ${config.envKeys.join(', ')}`);
94
+ }
95
+
96
+ const storage = new FileStorage({
97
+ filePath: join(tmpdir(), `codedex_multi_${providerName}_state.json`),
98
+ clearOnInit: true,
99
+ });
100
+
101
+ const manager = new ApiKeyManager(keys, {
102
+ storage,
103
+ strategy: config.strategy || new LatencyStrategy(),
104
+ concurrency: config.concurrency ?? 20,
105
+ semanticCache: config.semanticCache,
106
+ });
107
+
108
+ // Wire events
109
+ manager.on('keyDead', (key) =>
110
+ this.logger.error(`[MultiManager:${providerName}] Key DEAD: ...${key.slice(-4)}`));
111
+ manager.on('circuitOpen', (key) =>
112
+ this.logger.warn(`[MultiManager:${providerName}] Circuit OPEN: ...${key.slice(-4)}`));
113
+ manager.on('keyRecovered', (key) =>
114
+ this.logger.info(`[MultiManager:${providerName}] Key RECOVERED: ...${key.slice(-4)}`));
115
+ manager.on('allKeysExhausted', () =>
116
+ this.logger.error(`[MultiManager:${providerName}] ALL KEYS EXHAUSTED`));
117
+
118
+ // Health checks
119
+ const interval = options.healthCheckIntervalMs ?? 300_000;
120
+ if (interval > 0) {
121
+ manager.startHealthChecks(interval);
122
+ }
123
+
124
+ this.managers.set(providerName, manager);
125
+ this.logger.info(
126
+ `[MultiManager:${providerName}] Initialized with ${keys.length} keys`
127
+ );
128
+ }
129
+ }
130
+
131
+ // ─── Factory ────────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Get or create the singleton MultiManager instance.
135
+ */
136
+ static getInstance(options: MultiManagerOptions): Result<MultiManager> {
137
+ if (MultiManager.instance) {
138
+ return { success: true, data: MultiManager.instance };
139
+ }
140
+
141
+ try {
142
+ MultiManager.instance = new MultiManager(options);
143
+ return { success: true, data: MultiManager.instance };
144
+ } catch (error) {
145
+ return {
146
+ success: false,
147
+ error: error instanceof Error ? error : new Error(String(error)),
148
+ };
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Reset the singleton (primarily for testing).
154
+ */
155
+ static reset(): void {
156
+ if (MultiManager.instance) {
157
+ MultiManager.instance.destroy();
158
+ MultiManager.instance = null;
159
+ }
160
+ }
161
+
162
+ // ─── Public API ─────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Execute a function with a specific provider's key pool.
166
+ *
167
+ * @param fn - The function to execute with a key
168
+ * @param options - Must include `provider` to select the pool
169
+ */
170
+ async execute<T>(
171
+ fn: (key: string, signal?: AbortSignal) => Promise<T>,
172
+ options: ExecuteOptions & { prompt?: string; provider: string }
173
+ ): Promise<T> {
174
+ const manager = this.managers.get(options.provider);
175
+ if (!manager) {
176
+ throw new Error(
177
+ `[MultiManager] Unknown provider "${options.provider}". ` +
178
+ `Available: ${[...this.managers.keys()].join(', ')}`
179
+ );
180
+ }
181
+ // Strip `provider` before delegating — each manager only has keys for one provider
182
+ const { provider, ...delegateOptions } = options;
183
+ return manager.execute(fn, delegateOptions);
184
+ }
185
+
186
+ /**
187
+ * Execute a streaming function with a specific provider's key pool.
188
+ */
189
+ async *executeStream<T>(
190
+ fn: (key: string, signal?: AbortSignal) => AsyncGenerator<T, any, unknown>,
191
+ options: ExecuteOptions & { prompt?: string; provider: string }
192
+ ): AsyncGenerator<T, any, unknown> {
193
+ const manager = this.managers.get(options.provider);
194
+ if (!manager) {
195
+ throw new Error(
196
+ `[MultiManager] Unknown provider "${options.provider}". ` +
197
+ `Available: ${[...this.managers.keys()].join(', ')}`
198
+ );
199
+ }
200
+ const { provider, ...delegateOptions } = options;
201
+ yield* manager.executeStream(fn, delegateOptions);
202
+ }
203
+
204
+ /**
205
+ * Get a raw key from a specific provider pool.
206
+ */
207
+ getKey(provider: string): string | null {
208
+ const manager = this.managers.get(provider);
209
+ if (!manager) return null;
210
+ return manager.getKey();
211
+ }
212
+
213
+ /**
214
+ * Get stats for a specific provider.
215
+ */
216
+ getStats(provider: string): ApiKeyManagerStats | null {
217
+ const manager = this.managers.get(provider);
218
+ if (!manager) return null;
219
+ return manager.getStats();
220
+ }
221
+
222
+ /**
223
+ * Get aggregate stats across ALL providers.
224
+ */
225
+ getMultiStats(): Record<string, ApiKeyManagerStats> {
226
+ const stats: Record<string, ApiKeyManagerStats> = {};
227
+ for (const [name, manager] of this.managers) {
228
+ stats[name] = manager.getStats();
229
+ }
230
+ return stats;
231
+ }
232
+
233
+ /**
234
+ * Get the list of available provider names.
235
+ */
236
+ getProviders(): string[] {
237
+ return [...this.managers.keys()];
238
+ }
239
+
240
+ /**
241
+ * Get the underlying ApiKeyManager for a specific provider.
242
+ */
243
+ getManager(provider: string): ApiKeyManager | undefined {
244
+ return this.managers.get(provider);
245
+ }
246
+
247
+ /**
248
+ * Stop all health checks and clean up.
249
+ */
250
+ destroy(): void {
251
+ for (const [, manager] of this.managers) {
252
+ manager.stopHealthChecks();
253
+ }
254
+ this.managers.clear();
255
+ }
256
+
257
+ // ─── Helpers ────────────────────────────────────────────────────────────
258
+
259
+ private static parseKeysFromEnv(envKeys: string[]): string[] {
260
+ const keys: string[] = [];
261
+ for (const envName of envKeys) {
262
+ const envValue = process.env[envName];
263
+ if (!envValue) continue;
264
+ const trimmed = envValue.trim();
265
+ if (!trimmed) continue;
266
+
267
+ if (trimmed.startsWith('[')) {
268
+ try {
269
+ const parsed = JSON.parse(trimmed);
270
+ if (Array.isArray(parsed)) {
271
+ keys.push(...parsed.filter((k: any) => typeof k === 'string' && k.trim()));
272
+ continue;
273
+ }
274
+ } catch { /* not JSON */ }
275
+ }
276
+ keys.push(...trimmed.split(',').map(k => k.trim()).filter(k => k.length > 0));
277
+ }
278
+ return [...new Set(keys)];
279
+ }
280
+ }