@splashcodex/api-key-manager 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -206
- package/bin/cli.js +180 -180
- package/dist/env/loader.js +14 -30
- package/dist/env/loader.js.map +1 -1
- package/dist/index.d.ts +30 -13
- package/dist/index.js +106 -31
- package/dist/index.js.map +1 -1
- package/dist/persistence/file.js +12 -1
- package/dist/persistence/file.js.map +1 -1
- package/dist/presets/base.d.ts +6 -0
- package/dist/presets/base.js +37 -1
- package/dist/presets/base.js.map +1 -1
- package/package.json +155 -151
- package/src/env/index.ts +14 -14
- package/src/env/loader.ts +225 -249
- package/src/index.ts +1155 -1071
- package/src/persistence/file.ts +96 -89
- package/src/persistence/index.ts +7 -7
- package/src/persistence/memory.ts +43 -43
- package/src/presets/anthropic.ts +78 -78
- package/src/presets/base.ts +383 -344
- package/src/presets/gemini.ts +84 -84
- package/src/presets/index.ts +11 -11
- package/src/presets/multi.ts +290 -290
- package/src/presets/openai.ts +69 -69
package/src/presets/base.ts
CHANGED
|
@@ -1,344 +1,383 @@
|
|
|
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, basename } from 'path';
|
|
24
|
-
import { tmpdir, homedir } from 'os';
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
| { success:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
strategy
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
logger
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* /home/codedex/projects/
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
protected
|
|
111
|
-
protected
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
this.manager.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
`
|
|
150
|
-
`
|
|
151
|
-
`
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* -
|
|
244
|
-
* -
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
*
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const existing = BasePreset.instances.get(
|
|
329
|
-
if (existing) {
|
|
330
|
-
existing
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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, basename } from 'path';
|
|
24
|
+
import { tmpdir, homedir } from 'os';
|
|
25
|
+
import { readFileSync, existsSync } from 'fs';
|
|
26
|
+
import { createHash } from 'crypto';
|
|
27
|
+
|
|
28
|
+
// ─── Result Type ─────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A discriminated union for safe error handling without exceptions.
|
|
32
|
+
* Used by `getInstance()` to avoid crashing on missing env vars.
|
|
33
|
+
*/
|
|
34
|
+
export type Result<T> =
|
|
35
|
+
| { success: true; data: T }
|
|
36
|
+
| { success: false; error: Error };
|
|
37
|
+
|
|
38
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface PresetOptions {
|
|
41
|
+
/** Environment variable name(s) to read API keys from. */
|
|
42
|
+
envKeys: string[];
|
|
43
|
+
/** Provider name for logging and file state isolation. Default: 'default' */
|
|
44
|
+
provider?: string;
|
|
45
|
+
/** Load balancing strategy. Default: LatencyStrategy */
|
|
46
|
+
strategy?: LoadBalancingStrategy;
|
|
47
|
+
/** Max concurrent execute() calls. Default: 20 */
|
|
48
|
+
concurrency?: number;
|
|
49
|
+
/** Health check interval in ms. Set to 0 to disable. Default: 300_000 (5 min) */
|
|
50
|
+
healthCheckIntervalMs?: number;
|
|
51
|
+
/** Custom health check function. If not set, health checks are disabled. */
|
|
52
|
+
healthCheckFn?: (key: string) => Promise<boolean>;
|
|
53
|
+
/** Semantic cache config. Optional. */
|
|
54
|
+
semanticCache?: ApiKeyManagerOptions['semanticCache'];
|
|
55
|
+
/** Custom logger. Defaults to console. */
|
|
56
|
+
logger?: PresetLogger;
|
|
57
|
+
/** Fallback function when all keys are exhausted. */
|
|
58
|
+
fallbackFn?: () => any;
|
|
59
|
+
/** Custom state file path. Defaults to `os.tmpdir()/codedex_{provider}_state.json` */
|
|
60
|
+
stateFilePath?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PresetLogger {
|
|
64
|
+
info(message: string, ...args: any[]): void;
|
|
65
|
+
warn(message: string, ...args: any[]): void;
|
|
66
|
+
error(message: string, ...args: any[]): void;
|
|
67
|
+
}
|
|
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
|
+
|
|
87
|
+
// ─── Base Preset Class ──────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Abstract base class for provider presets.
|
|
91
|
+
* Subclasses only need to define `getDefaultOptions()` with provider-specific
|
|
92
|
+
* defaults (env var name, health check function, etc.).
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* // Creating a custom preset:
|
|
97
|
+
* class MyProviderPreset extends BasePreset {
|
|
98
|
+
* protected static getDefaultOptions(): Partial<PresetOptions> {
|
|
99
|
+
* return {
|
|
100
|
+
* envKeys: ['MY_PROVIDER_API_KEY'],
|
|
101
|
+
* provider: 'my-provider',
|
|
102
|
+
* };
|
|
103
|
+
* }
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export abstract class BasePreset {
|
|
108
|
+
private static instances: Map<string, BasePreset> = new Map();
|
|
109
|
+
|
|
110
|
+
protected manager: ApiKeyManager;
|
|
111
|
+
protected logger: PresetLogger;
|
|
112
|
+
protected options: Required<Pick<PresetOptions, 'provider' | 'concurrency' | 'healthCheckIntervalMs'>> & PresetOptions;
|
|
113
|
+
|
|
114
|
+
protected constructor(apiKeys: string[], options: PresetOptions) {
|
|
115
|
+
this.logger = options.logger || console;
|
|
116
|
+
|
|
117
|
+
this.options = {
|
|
118
|
+
provider: options.provider || 'default',
|
|
119
|
+
concurrency: options.concurrency ?? 20,
|
|
120
|
+
healthCheckIntervalMs: options.healthCheckIntervalMs ?? 300_000,
|
|
121
|
+
...options,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const projectId = getProjectId();
|
|
125
|
+
const stateFile = this.options.stateFilePath ||
|
|
126
|
+
join(tmpdir(), `codedex_${this.options.provider}_${projectId}_state.json`);
|
|
127
|
+
|
|
128
|
+
const storage = new FileStorage({
|
|
129
|
+
filePath: stateFile,
|
|
130
|
+
clearOnInit: false, // Preserve circuit breaker state across restarts
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this.manager = new ApiKeyManager(apiKeys, {
|
|
134
|
+
storage,
|
|
135
|
+
strategy: this.options.strategy || new LatencyStrategy(),
|
|
136
|
+
fallbackFn: this.options.fallbackFn,
|
|
137
|
+
concurrency: this.options.concurrency,
|
|
138
|
+
semanticCache: this.options.semanticCache,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
this.wireEvents();
|
|
142
|
+
|
|
143
|
+
if (this.options.healthCheckFn && this.options.healthCheckIntervalMs > 0) {
|
|
144
|
+
this.manager.setHealthCheck(this.options.healthCheckFn);
|
|
145
|
+
this.manager.startHealthChecks(this.options.healthCheckIntervalMs);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.logger.info(
|
|
149
|
+
`[${this.options.provider}] ApiKeyManager initialized with ${apiKeys.length} keys ` +
|
|
150
|
+
`(Strategy: ${this.options.strategy?.constructor.name || 'LatencyStrategy'}, ` +
|
|
151
|
+
`Concurrency: ${this.options.concurrency}, ` +
|
|
152
|
+
`HealthChecks: ${this.options.healthCheckIntervalMs > 0 ? `every ${this.options.healthCheckIntervalMs / 1000}s` : 'disabled'})`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wire all manager events to the logger.
|
|
158
|
+
*/
|
|
159
|
+
private wireEvents(): void {
|
|
160
|
+
const tag = this.options.provider;
|
|
161
|
+
this.manager.on('keyDead', (key) =>
|
|
162
|
+
this.logger.error(`[${tag}] Key PERMANENTLY DEAD: ...${key.slice(-4)}`));
|
|
163
|
+
this.manager.on('circuitOpen', (key) =>
|
|
164
|
+
this.logger.warn(`[${tag}] Circuit OPEN (cooldown): ...${key.slice(-4)}`));
|
|
165
|
+
this.manager.on('keyRecovered', (key) =>
|
|
166
|
+
this.logger.info(`[${tag}] Key RECOVERED: ...${key.slice(-4)}`));
|
|
167
|
+
this.manager.on('retry', (key, attempt, delay) =>
|
|
168
|
+
this.logger.info(`[${tag}] Retry with ...${key.slice(-4)} (Attempt ${attempt}, Delay ${delay}ms)`));
|
|
169
|
+
this.manager.on('fallback', (reason) =>
|
|
170
|
+
this.logger.warn(`[${tag}] Triggering FALLBACK: ${reason}`));
|
|
171
|
+
this.manager.on('allKeysExhausted', () =>
|
|
172
|
+
this.logger.error(`[${tag}] ALL KEYS EXHAUSTED! No fallback available.`));
|
|
173
|
+
this.manager.on('bulkheadRejected', () =>
|
|
174
|
+
this.logger.warn(`[${tag}] Bulkhead rejected request (concurrency limit reached)`));
|
|
175
|
+
this.manager.on('healthCheckPassed', (key) =>
|
|
176
|
+
this.logger.info(`[${tag}] Health check PASSED: ...${key.slice(-4)}`));
|
|
177
|
+
this.manager.on('healthCheckFailed', (key) =>
|
|
178
|
+
this.logger.warn(`[${tag}] Health check FAILED: ...${key.slice(-4)}`));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Public API ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Execute a function with automatic key rotation, retries, timeout, and caching.
|
|
185
|
+
*/
|
|
186
|
+
async execute<T>(
|
|
187
|
+
fn: (key: string, signal?: AbortSignal) => Promise<T>,
|
|
188
|
+
options?: ExecuteOptions & { prompt?: string }
|
|
189
|
+
): Promise<T> {
|
|
190
|
+
return this.manager.execute(fn, options);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Execute a streaming function with retry on initial connection failure.
|
|
195
|
+
*/
|
|
196
|
+
async *executeStream<T>(
|
|
197
|
+
fn: (key: string, signal?: AbortSignal) => AsyncGenerator<T, any, unknown>,
|
|
198
|
+
options?: ExecuteOptions & { prompt?: string }
|
|
199
|
+
): AsyncGenerator<T, any, unknown> {
|
|
200
|
+
yield* this.manager.executeStream(fn, options);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the best available API key directly (low-level).
|
|
205
|
+
*/
|
|
206
|
+
getKey(): string | null {
|
|
207
|
+
return this.manager.getKey();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get the number of non-dead keys.
|
|
212
|
+
*/
|
|
213
|
+
getKeyCount(): number {
|
|
214
|
+
return this.manager.getKeyCount();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get pool health statistics.
|
|
219
|
+
*/
|
|
220
|
+
getStats() {
|
|
221
|
+
return this.manager.getStats();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get the underlying ApiKeyManager instance for advanced use.
|
|
226
|
+
*/
|
|
227
|
+
getManager(): ApiKeyManager {
|
|
228
|
+
return this.manager;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Stop health checks and clean up. Call when shutting down.
|
|
233
|
+
*/
|
|
234
|
+
destroy(): void {
|
|
235
|
+
this.manager.stopHealthChecks();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Static Factory ─────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Parse API keys from environment variables.
|
|
242
|
+
* Supports:
|
|
243
|
+
* - Single key: `"AIza..."`
|
|
244
|
+
* - JSON array: `'["AIza...", "AIzb..."]'`
|
|
245
|
+
* - Comma-separated: `"AIza...,AIzb..."`
|
|
246
|
+
*/
|
|
247
|
+
protected static parseKeysFromEnv(envKeys: string[]): string[] {
|
|
248
|
+
const keys: string[] = [];
|
|
249
|
+
|
|
250
|
+
for (const envName of envKeys) {
|
|
251
|
+
const envValue = process.env[envName];
|
|
252
|
+
if (!envValue) continue;
|
|
253
|
+
|
|
254
|
+
const trimmed = envValue.trim();
|
|
255
|
+
if (!trimmed) continue;
|
|
256
|
+
|
|
257
|
+
// Try JSON array
|
|
258
|
+
if (trimmed.startsWith('[')) {
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(trimmed);
|
|
261
|
+
if (Array.isArray(parsed)) {
|
|
262
|
+
keys.push(...parsed.filter((k: any) => typeof k === 'string' && k.trim()));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// Not valid JSON, fall through to comma split
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Comma-separated or single key
|
|
271
|
+
keys.push(...trimmed.split(',').map(k => k.trim()).filter(k => k.length > 0));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return [...new Set(keys)]; // Deduplicate
|
|
275
|
+
}
|
|
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
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get or create the singleton instance for a preset.
|
|
313
|
+
* This is the core factory used by all preset subclasses.
|
|
314
|
+
*
|
|
315
|
+
* @param PresetClass - The preset class to instantiate
|
|
316
|
+
* @param overrides - Optional overrides for preset options
|
|
317
|
+
* @returns Result<T> — either `{ success: true, data: instance }` or `{ success: false, error }`
|
|
318
|
+
*/
|
|
319
|
+
protected static createInstance<T extends BasePreset>(
|
|
320
|
+
PresetClass: new (keys: string[], options: PresetOptions) => T,
|
|
321
|
+
defaultOptions: Partial<PresetOptions>,
|
|
322
|
+
overrides?: Partial<PresetOptions>
|
|
323
|
+
): Result<T> {
|
|
324
|
+
const mergedOptions = { ...defaultOptions, ...overrides } as PresetOptions;
|
|
325
|
+
const instanceKey = mergedOptions.provider || PresetClass.name;
|
|
326
|
+
|
|
327
|
+
// Return existing singleton
|
|
328
|
+
const existing = BasePreset.instances.get(instanceKey);
|
|
329
|
+
if (existing) {
|
|
330
|
+
return { success: true, data: existing as T };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Parse keys
|
|
334
|
+
const envKeys = mergedOptions.envKeys || [];
|
|
335
|
+
let keys = BasePreset.parseKeysFromEnv(envKeys);
|
|
336
|
+
|
|
337
|
+
if (keys.length === 0) {
|
|
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
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const instance = new PresetClass(keys, mergedOptions);
|
|
353
|
+
BasePreset.instances.set(instanceKey, instance);
|
|
354
|
+
return { success: true, data: instance };
|
|
355
|
+
} catch (error) {
|
|
356
|
+
return {
|
|
357
|
+
success: false,
|
|
358
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Reset a singleton instance (primarily for testing).
|
|
365
|
+
*/
|
|
366
|
+
protected static resetInstance(provider: string): void {
|
|
367
|
+
const existing = BasePreset.instances.get(provider);
|
|
368
|
+
if (existing) {
|
|
369
|
+
existing.destroy();
|
|
370
|
+
BasePreset.instances.delete(provider);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Reset ALL singleton instances (primarily for testing).
|
|
376
|
+
*/
|
|
377
|
+
static resetAll(): void {
|
|
378
|
+
for (const [, instance] of BasePreset.instances) {
|
|
379
|
+
instance.destroy();
|
|
380
|
+
}
|
|
381
|
+
BasePreset.instances.clear();
|
|
382
|
+
}
|
|
383
|
+
}
|