@splashcodex/api-key-manager 5.2.0 → 5.4.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.
@@ -1,290 +1,290 @@
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, basename } from 'path';
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
- }
55
-
56
- // ─── Types ──────────────────────────────────────────────────────────────────
57
-
58
- export type Result<T> =
59
- | { success: true; data: T }
60
- | { success: false; error: Error };
61
-
62
- export interface ProviderConfig {
63
- /** Environment variable names to read API keys from */
64
- envKeys: string[];
65
- /** Strategy override. Default: LatencyStrategy */
66
- strategy?: LoadBalancingStrategy;
67
- /** Concurrency limit. Default: 20 */
68
- concurrency?: number;
69
- /** Semantic cache configuration. Optional. */
70
- semanticCache?: ApiKeyManagerOptions['semanticCache'];
71
- }
72
-
73
- export interface MultiManagerOptions {
74
- /** Provider configurations keyed by name (e.g., 'gemini', 'openai') */
75
- providers: Record<string, ProviderConfig>;
76
- /** Health check interval in ms. Default: 300_000 (5 min). Set to 0 to disable. */
77
- healthCheckIntervalMs?: number;
78
- /** Custom logger. Defaults to console. */
79
- logger?: {
80
- info(msg: string, ...args: any[]): void;
81
- warn(msg: string, ...args: any[]): void;
82
- error(msg: string, ...args: any[]): void;
83
- };
84
- }
85
-
86
- // ─── MultiManager ───────────────────────────────────────────────────────────
87
-
88
- export class MultiManager {
89
- private static instance: MultiManager | null = null;
90
-
91
- private managers = new Map<string, ApiKeyManager>();
92
- private logger: NonNullable<MultiManagerOptions['logger']>;
93
-
94
- private constructor(options: MultiManagerOptions) {
95
- this.logger = options.logger || console;
96
-
97
- for (const [providerName, config] of Object.entries(options.providers)) {
98
- const keys = MultiManager.parseKeysFromEnv(config.envKeys);
99
-
100
- if (keys.length === 0) {
101
- this.logger.warn(`[MultiManager:${providerName}] No API keys found in: ${config.envKeys.join(', ')} — provider skipped`);
102
- continue;
103
- }
104
-
105
- const projectId = getProjectId();
106
- const storage = new FileStorage({
107
- filePath: join(tmpdir(), `codedex_multi_${providerName}_${projectId}_state.json`),
108
- clearOnInit: false, // Preserve circuit breaker state across restarts
109
- });
110
-
111
- const manager = new ApiKeyManager(keys, {
112
- storage,
113
- strategy: config.strategy || new LatencyStrategy(),
114
- concurrency: config.concurrency ?? 20,
115
- semanticCache: config.semanticCache,
116
- });
117
-
118
- // Wire events
119
- manager.on('keyDead', (key) =>
120
- this.logger.error(`[MultiManager:${providerName}] Key DEAD: ...${key.slice(-4)}`));
121
- manager.on('circuitOpen', (key) =>
122
- this.logger.warn(`[MultiManager:${providerName}] Circuit OPEN: ...${key.slice(-4)}`));
123
- manager.on('keyRecovered', (key) =>
124
- this.logger.info(`[MultiManager:${providerName}] Key RECOVERED: ...${key.slice(-4)}`));
125
- manager.on('allKeysExhausted', () =>
126
- this.logger.error(`[MultiManager:${providerName}] ALL KEYS EXHAUSTED`));
127
-
128
- // Health checks
129
- const interval = options.healthCheckIntervalMs ?? 300_000;
130
- if (interval > 0) {
131
- manager.startHealthChecks(interval);
132
- }
133
-
134
- this.managers.set(providerName, manager);
135
- this.logger.info(
136
- `[MultiManager:${providerName}] Initialized with ${keys.length} keys`
137
- );
138
- }
139
- }
140
-
141
- // ─── Factory ────────────────────────────────────────────────────────────
142
-
143
- /**
144
- * Get or create the singleton MultiManager instance.
145
- */
146
- static getInstance(options: MultiManagerOptions): Result<MultiManager> {
147
- if (MultiManager.instance) {
148
- return { success: true, data: MultiManager.instance };
149
- }
150
-
151
- try {
152
- MultiManager.instance = new MultiManager(options);
153
- return { success: true, data: MultiManager.instance };
154
- } catch (error) {
155
- return {
156
- success: false,
157
- error: error instanceof Error ? error : new Error(String(error)),
158
- };
159
- }
160
- }
161
-
162
- /**
163
- * Reset the singleton (primarily for testing).
164
- */
165
- static reset(): void {
166
- if (MultiManager.instance) {
167
- MultiManager.instance.destroy();
168
- MultiManager.instance = null;
169
- }
170
- }
171
-
172
- // ─── Public API ─────────────────────────────────────────────────────────
173
-
174
- /**
175
- * Execute a function with a specific provider's key pool.
176
- *
177
- * @param fn - The function to execute with a key
178
- * @param options - Must include `provider` to select the pool
179
- */
180
- async execute<T>(
181
- fn: (key: string, signal?: AbortSignal) => Promise<T>,
182
- options: ExecuteOptions & { prompt?: string; provider: string }
183
- ): Promise<T> {
184
- const manager = this.managers.get(options.provider);
185
- if (!manager) {
186
- throw new Error(
187
- `[MultiManager] Unknown provider "${options.provider}". ` +
188
- `Available: ${[...this.managers.keys()].join(', ')}`
189
- );
190
- }
191
- // Strip `provider` before delegating — each manager only has keys for one provider
192
- const { provider, ...delegateOptions } = options;
193
- return manager.execute(fn, delegateOptions);
194
- }
195
-
196
- /**
197
- * Execute a streaming function with a specific provider's key pool.
198
- */
199
- async *executeStream<T>(
200
- fn: (key: string, signal?: AbortSignal) => AsyncGenerator<T, any, unknown>,
201
- options: ExecuteOptions & { prompt?: string; provider: string }
202
- ): AsyncGenerator<T, any, unknown> {
203
- const manager = this.managers.get(options.provider);
204
- if (!manager) {
205
- throw new Error(
206
- `[MultiManager] Unknown provider "${options.provider}". ` +
207
- `Available: ${[...this.managers.keys()].join(', ')}`
208
- );
209
- }
210
- const { provider, ...delegateOptions } = options;
211
- yield* manager.executeStream(fn, delegateOptions);
212
- }
213
-
214
- /**
215
- * Get a raw key from a specific provider pool.
216
- */
217
- getKey(provider: string): string | null {
218
- const manager = this.managers.get(provider);
219
- if (!manager) return null;
220
- return manager.getKey();
221
- }
222
-
223
- /**
224
- * Get stats for a specific provider.
225
- */
226
- getStats(provider: string): ApiKeyManagerStats | null {
227
- const manager = this.managers.get(provider);
228
- if (!manager) return null;
229
- return manager.getStats();
230
- }
231
-
232
- /**
233
- * Get aggregate stats across ALL providers.
234
- */
235
- getMultiStats(): Record<string, ApiKeyManagerStats> {
236
- const stats: Record<string, ApiKeyManagerStats> = {};
237
- for (const [name, manager] of this.managers) {
238
- stats[name] = manager.getStats();
239
- }
240
- return stats;
241
- }
242
-
243
- /**
244
- * Get the list of available provider names.
245
- */
246
- getProviders(): string[] {
247
- return [...this.managers.keys()];
248
- }
249
-
250
- /**
251
- * Get the underlying ApiKeyManager for a specific provider.
252
- */
253
- getManager(provider: string): ApiKeyManager | undefined {
254
- return this.managers.get(provider);
255
- }
256
-
257
- /**
258
- * Stop all health checks and clean up.
259
- */
260
- destroy(): void {
261
- for (const [, manager] of this.managers) {
262
- manager.stopHealthChecks();
263
- }
264
- this.managers.clear();
265
- }
266
-
267
- // ─── Helpers ────────────────────────────────────────────────────────────
268
-
269
- private static parseKeysFromEnv(envKeys: string[]): string[] {
270
- const keys: string[] = [];
271
- for (const envName of envKeys) {
272
- const envValue = process.env[envName];
273
- if (!envValue) continue;
274
- const trimmed = envValue.trim();
275
- if (!trimmed) continue;
276
-
277
- if (trimmed.startsWith('[')) {
278
- try {
279
- const parsed = JSON.parse(trimmed);
280
- if (Array.isArray(parsed)) {
281
- keys.push(...parsed.filter((k: any) => typeof k === 'string' && k.trim()));
282
- continue;
283
- }
284
- } catch { /* not JSON */ }
285
- }
286
- keys.push(...trimmed.split(',').map(k => k.trim()).filter(k => k.length > 0));
287
- }
288
- return [...new Set(keys)];
289
- }
290
- }
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, basename } from 'path';
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
+ }
55
+
56
+ // ─── Types ──────────────────────────────────────────────────────────────────
57
+
58
+ export type Result<T> =
59
+ | { success: true; data: T }
60
+ | { success: false; error: Error };
61
+
62
+ export interface ProviderConfig {
63
+ /** Environment variable names to read API keys from */
64
+ envKeys: string[];
65
+ /** Strategy override. Default: LatencyStrategy */
66
+ strategy?: LoadBalancingStrategy;
67
+ /** Concurrency limit. Default: 20 */
68
+ concurrency?: number;
69
+ /** Semantic cache configuration. Optional. */
70
+ semanticCache?: ApiKeyManagerOptions['semanticCache'];
71
+ }
72
+
73
+ export interface MultiManagerOptions {
74
+ /** Provider configurations keyed by name (e.g., 'gemini', 'openai') */
75
+ providers: Record<string, ProviderConfig>;
76
+ /** Health check interval in ms. Default: 300_000 (5 min). Set to 0 to disable. */
77
+ healthCheckIntervalMs?: number;
78
+ /** Custom logger. Defaults to console. */
79
+ logger?: {
80
+ info(msg: string, ...args: any[]): void;
81
+ warn(msg: string, ...args: any[]): void;
82
+ error(msg: string, ...args: any[]): void;
83
+ };
84
+ }
85
+
86
+ // ─── MultiManager ───────────────────────────────────────────────────────────
87
+
88
+ export class MultiManager {
89
+ private static instance: MultiManager | null = null;
90
+
91
+ private managers = new Map<string, ApiKeyManager>();
92
+ private logger: NonNullable<MultiManagerOptions['logger']>;
93
+
94
+ private constructor(options: MultiManagerOptions) {
95
+ this.logger = options.logger || console;
96
+
97
+ for (const [providerName, config] of Object.entries(options.providers)) {
98
+ const keys = MultiManager.parseKeysFromEnv(config.envKeys);
99
+
100
+ if (keys.length === 0) {
101
+ this.logger.warn(`[MultiManager:${providerName}] No API keys found in: ${config.envKeys.join(', ')} — provider skipped`);
102
+ continue;
103
+ }
104
+
105
+ const projectId = getProjectId();
106
+ const storage = new FileStorage({
107
+ filePath: join(tmpdir(), `codedex_multi_${providerName}_${projectId}_state.json`),
108
+ clearOnInit: false, // Preserve circuit breaker state across restarts
109
+ });
110
+
111
+ const manager = new ApiKeyManager(keys, {
112
+ storage,
113
+ strategy: config.strategy || new LatencyStrategy(),
114
+ concurrency: config.concurrency ?? 20,
115
+ semanticCache: config.semanticCache,
116
+ });
117
+
118
+ // Wire events
119
+ manager.on('keyDead', (key) =>
120
+ this.logger.error(`[MultiManager:${providerName}] Key DEAD: ...${key.slice(-4)}`));
121
+ manager.on('circuitOpen', (key) =>
122
+ this.logger.warn(`[MultiManager:${providerName}] Circuit OPEN: ...${key.slice(-4)}`));
123
+ manager.on('keyRecovered', (key) =>
124
+ this.logger.info(`[MultiManager:${providerName}] Key RECOVERED: ...${key.slice(-4)}`));
125
+ manager.on('allKeysExhausted', () =>
126
+ this.logger.error(`[MultiManager:${providerName}] ALL KEYS EXHAUSTED`));
127
+
128
+ // Health checks
129
+ const interval = options.healthCheckIntervalMs ?? 300_000;
130
+ if (interval > 0) {
131
+ manager.startHealthChecks(interval);
132
+ }
133
+
134
+ this.managers.set(providerName, manager);
135
+ this.logger.info(
136
+ `[MultiManager:${providerName}] Initialized with ${keys.length} keys`
137
+ );
138
+ }
139
+ }
140
+
141
+ // ─── Factory ────────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Get or create the singleton MultiManager instance.
145
+ */
146
+ static getInstance(options: MultiManagerOptions): Result<MultiManager> {
147
+ if (MultiManager.instance) {
148
+ return { success: true, data: MultiManager.instance };
149
+ }
150
+
151
+ try {
152
+ MultiManager.instance = new MultiManager(options);
153
+ return { success: true, data: MultiManager.instance };
154
+ } catch (error) {
155
+ return {
156
+ success: false,
157
+ error: error instanceof Error ? error : new Error(String(error)),
158
+ };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Reset the singleton (primarily for testing).
164
+ */
165
+ static reset(): void {
166
+ if (MultiManager.instance) {
167
+ MultiManager.instance.destroy();
168
+ MultiManager.instance = null;
169
+ }
170
+ }
171
+
172
+ // ─── Public API ─────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Execute a function with a specific provider's key pool.
176
+ *
177
+ * @param fn - The function to execute with a key
178
+ * @param options - Must include `provider` to select the pool
179
+ */
180
+ async execute<T>(
181
+ fn: (key: string, signal?: AbortSignal) => Promise<T>,
182
+ options: ExecuteOptions & { prompt?: string; provider: string }
183
+ ): Promise<T> {
184
+ const manager = this.managers.get(options.provider);
185
+ if (!manager) {
186
+ throw new Error(
187
+ `[MultiManager] Unknown provider "${options.provider}". ` +
188
+ `Available: ${[...this.managers.keys()].join(', ')}`
189
+ );
190
+ }
191
+ // Strip `provider` before delegating — each manager only has keys for one provider
192
+ const { provider, ...delegateOptions } = options;
193
+ return manager.execute(fn, delegateOptions);
194
+ }
195
+
196
+ /**
197
+ * Execute a streaming function with a specific provider's key pool.
198
+ */
199
+ async *executeStream<T>(
200
+ fn: (key: string, signal?: AbortSignal) => AsyncGenerator<T, any, unknown>,
201
+ options: ExecuteOptions & { prompt?: string; provider: string }
202
+ ): AsyncGenerator<T, any, unknown> {
203
+ const manager = this.managers.get(options.provider);
204
+ if (!manager) {
205
+ throw new Error(
206
+ `[MultiManager] Unknown provider "${options.provider}". ` +
207
+ `Available: ${[...this.managers.keys()].join(', ')}`
208
+ );
209
+ }
210
+ const { provider, ...delegateOptions } = options;
211
+ yield* manager.executeStream(fn, delegateOptions);
212
+ }
213
+
214
+ /**
215
+ * Get a raw key from a specific provider pool.
216
+ */
217
+ getKey(provider: string): string | null {
218
+ const manager = this.managers.get(provider);
219
+ if (!manager) return null;
220
+ return manager.getKey();
221
+ }
222
+
223
+ /**
224
+ * Get stats for a specific provider.
225
+ */
226
+ getStats(provider: string): ApiKeyManagerStats | null {
227
+ const manager = this.managers.get(provider);
228
+ if (!manager) return null;
229
+ return manager.getStats();
230
+ }
231
+
232
+ /**
233
+ * Get aggregate stats across ALL providers.
234
+ */
235
+ getMultiStats(): Record<string, ApiKeyManagerStats> {
236
+ const stats: Record<string, ApiKeyManagerStats> = {};
237
+ for (const [name, manager] of this.managers) {
238
+ stats[name] = manager.getStats();
239
+ }
240
+ return stats;
241
+ }
242
+
243
+ /**
244
+ * Get the list of available provider names.
245
+ */
246
+ getProviders(): string[] {
247
+ return [...this.managers.keys()];
248
+ }
249
+
250
+ /**
251
+ * Get the underlying ApiKeyManager for a specific provider.
252
+ */
253
+ getManager(provider: string): ApiKeyManager | undefined {
254
+ return this.managers.get(provider);
255
+ }
256
+
257
+ /**
258
+ * Stop all health checks and clean up.
259
+ */
260
+ destroy(): void {
261
+ for (const [, manager] of this.managers) {
262
+ manager.stopHealthChecks();
263
+ }
264
+ this.managers.clear();
265
+ }
266
+
267
+ // ─── Helpers ────────────────────────────────────────────────────────────
268
+
269
+ private static parseKeysFromEnv(envKeys: string[]): string[] {
270
+ const keys: string[] = [];
271
+ for (const envName of envKeys) {
272
+ const envValue = process.env[envName];
273
+ if (!envValue) continue;
274
+ const trimmed = envValue.trim();
275
+ if (!trimmed) continue;
276
+
277
+ if (trimmed.startsWith('[')) {
278
+ try {
279
+ const parsed = JSON.parse(trimmed);
280
+ if (Array.isArray(parsed)) {
281
+ keys.push(...parsed.filter((k: any) => typeof k === 'string' && k.trim()));
282
+ continue;
283
+ }
284
+ } catch { /* not JSON */ }
285
+ }
286
+ keys.push(...trimmed.split(',').map(k => k.trim()).filter(k => k.length > 0));
287
+ }
288
+ return [...new Set(keys)];
289
+ }
290
+ }