@splashcodex/api-key-manager 1.0.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 ADDED
@@ -0,0 +1,81 @@
1
+ # @splashcodex/api-key-manager
2
+
3
+ A robust, universal API Key Rotation and Management system designed for high-availability applications using rate-limited APIs (like Google Gemini).
4
+
5
+ ## Features
6
+
7
+ - **🔄 Automatic Key Rotation**: Seamlessly switches to the next available key upon exhaustion.
8
+ - **🔌 Circuit Breaker Pattern**: Automatically "opens" the circuit for keys that return 429s or 500s, preventing wasted requests.
9
+ - **💾 Persistence**: Remembers key states (failures, cooldowns) across restarts using local storage.
10
+ - **🧠 Smart Error Classification**: Distinguishes between transient errors (retryable), quota errors (cooldown), and auth errors (dead).
11
+ - **⏱️ Jittered Exponential Backoff**: Prevents thundering herd problems during retries.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @splashcodex/api-key-manager
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### 1. Initialize
22
+
23
+ ```typescript
24
+ import { ApiKeyManager } from '@splashcodex/api-key-manager';
25
+
26
+ // Initialize with a pool of keys
27
+ const apiKeys = [
28
+ "AIzaSy...",
29
+ "AIzaSy...",
30
+ "AIzaSy..."
31
+ ];
32
+
33
+ const manager = new ApiKeyManager(apiKeys);
34
+ ```
35
+
36
+ ### 2. Get a Key
37
+
38
+ ```typescript
39
+ const key = manager.getKey();
40
+
41
+ if (!key) {
42
+ throw new Error("All API keys are exhausted or cooling down.");
43
+ }
44
+
45
+ // Use the key with your API client
46
+ const client = new GoogleGenerativeAI(key);
47
+ ```
48
+
49
+ ### 3. Report Results (The Feedback Loop)
50
+
51
+ Crucial Step: You must report success or failure back to the manager so it can update the circuit state.
52
+
53
+ ```typescript
54
+ try {
55
+ const response = await client.generateContent(prompt);
56
+
57
+ // ✅ REPORT SUCCESS
58
+ manager.markSuccess(key);
59
+
60
+ return response;
61
+ } catch (error) {
62
+ // ❌ REPORT FAILURE
63
+ // The manager will automatically classify the error (Quota vs Auth vs Transient)
64
+ const classification = manager.classifyError(error);
65
+ manager.markFailed(key, classification);
66
+
67
+ throw error; // Re-throw or handle accordingly
68
+ }
69
+ ```
70
+
71
+ ## Error Classification
72
+
73
+ The `classifyError` method automatically detects:
74
+ - **429 / Quota**: Marks key as 'OPEN' for a cooldown period (default 5 mins).
75
+ - **403 / Auth**: Marks key as 'DEAD' permanently.
76
+ - **500 / Transient**: Marks key as 'OPEN' for a short cooldown (default 1 min).
77
+ - **FinishReason: SAFETY/RECITATION**: specific to Gemini, does not penalize the key.
78
+
79
+ ## License
80
+
81
+ ISC
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Universal ApiKeyManager v2.0
3
+ * Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff
4
+ * Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
5
+ */
6
+ export interface KeyState {
7
+ key: string;
8
+ failCount: number;
9
+ failedAt: number | null;
10
+ isQuotaError: boolean;
11
+ circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN' | 'DEAD';
12
+ lastUsed: number;
13
+ successCount: number;
14
+ totalRequests: number;
15
+ halfOpenTestTime: number | null;
16
+ customCooldown: number | null;
17
+ }
18
+ export type ErrorType = 'QUOTA' | 'TRANSIENT' | 'AUTH' | 'BAD_REQUEST' | 'SAFETY' | 'RECITATION' | 'UNKNOWN';
19
+ export interface ErrorClassification {
20
+ type: ErrorType;
21
+ retryable: boolean;
22
+ cooldownMs: number;
23
+ markKeyFailed: boolean;
24
+ markKeyDead: boolean;
25
+ }
26
+ export interface ApiKeyManagerStats {
27
+ total: number;
28
+ healthy: number;
29
+ cooling: number;
30
+ dead: number;
31
+ }
32
+ export declare class ApiKeyManager {
33
+ private keys;
34
+ private storageKey;
35
+ private storage;
36
+ constructor(initialKeys: string[], storage?: any);
37
+ /**
38
+ * CLASSIFIES an error to determine handling strategy
39
+ */
40
+ classifyError(error: any, finishReason?: string): ErrorClassification;
41
+ /**
42
+ * Parses Retry-After header from error response
43
+ */
44
+ private parseRetryAfter;
45
+ /**
46
+ * HEALTH CHECK
47
+ * Determines if a key is usable based on Circuit Breaker logic
48
+ */
49
+ private isOnCooldown;
50
+ /**
51
+ * CORE ROTATION LOGIC
52
+ * Returns the best available key
53
+ */
54
+ getKey(): string | null;
55
+ /**
56
+ * Get count of healthy (non-DEAD) keys
57
+ */
58
+ getKeyCount(): number;
59
+ /**
60
+ * FEEDBACK LOOP: Success
61
+ */
62
+ markSuccess(key: string): void;
63
+ /**
64
+ * FEEDBACK LOOP: Failure
65
+ * Enhanced with error classification
66
+ */
67
+ markFailed(key: string, classification: ErrorClassification): void;
68
+ /**
69
+ * Legacy markFailed for backward compatibility
70
+ */
71
+ markFailedLegacy(key: string, isQuota?: boolean): void;
72
+ /**
73
+ * Calculate backoff delay with jitter
74
+ */
75
+ calculateBackoff(attempt: number): number;
76
+ /**
77
+ * Get health statistics
78
+ */
79
+ getStats(): ApiKeyManagerStats;
80
+ _getKeys(): KeyState[];
81
+ private saveState;
82
+ private loadState;
83
+ }
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+ /**
3
+ * Universal ApiKeyManager v2.0
4
+ * Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff
5
+ * Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.ApiKeyManager = void 0;
9
+ const CONFIG = {
10
+ MAX_CONSECUTIVE_FAILURES: 5,
11
+ COOLDOWN_TRANSIENT: 60 * 1000, // 1 minute
12
+ COOLDOWN_QUOTA: 5 * 60 * 1000, // 5 minutes (default if no Retry-After)
13
+ COOLDOWN_QUOTA_DAILY: 60 * 60 * 1000, // 1 hour for RPD exhaustion
14
+ HALF_OPEN_TEST_DELAY: 60 * 1000, // 1 minute after open
15
+ MAX_BACKOFF: 64 * 1000, // 64 seconds max
16
+ BASE_BACKOFF: 1000, // 1 second base
17
+ };
18
+ // Error classification patterns
19
+ const ERROR_PATTERNS = {
20
+ isQuotaError: /429|quota|exhausted|resource.?exhausted|too.?many.?requests|rate.?limit/i,
21
+ isAuthError: /403|permission.?denied|invalid.?api.?key|unauthorized|unauthenticated/i,
22
+ isSafetyBlock: /safety|blocked|recitation|harmful/i,
23
+ isTransient: /500|502|503|504|internal|unavailable|deadline|timeout|overloaded/i,
24
+ isBadRequest: /400|invalid.?argument|failed.?precondition|malformed|not.?found|404/i,
25
+ };
26
+ class ApiKeyManager {
27
+ keys = [];
28
+ storageKey = 'api_rotation_state_v2';
29
+ // Simplified Storage interface for Node.js environment
30
+ storage;
31
+ constructor(initialKeys, storage) {
32
+ // Simple in-memory storage mock if none provided (for testing/Node)
33
+ this.storage = storage || {
34
+ getItem: () => null,
35
+ setItem: () => { },
36
+ };
37
+ // 1. Sanitize & Deduplicate Keys "Even Better"
38
+ const uniqueKeys = new Set();
39
+ initialKeys.forEach(rawKey => {
40
+ // Handle comma-separated keys if they appear in a single string
41
+ const parts = rawKey.split(',').map(s => s.trim()).filter(s => s.length > 0);
42
+ parts.forEach(p => uniqueKeys.add(p));
43
+ });
44
+ // 2. Warn about duplicates/empty
45
+ const sanitizedCount = uniqueKeys.size;
46
+ const rawLength = initialKeys.length; // Might be misleading if split happened, but roughly
47
+ if (sanitizedCount < rawLength) {
48
+ console.warn(`[ApiKeyManager] Optimized key pool: Removed ${rawLength - sanitizedCount} duplicate/empty keys.`);
49
+ }
50
+ this.keys = Array.from(uniqueKeys).map(k => ({
51
+ key: k,
52
+ failCount: 0,
53
+ failedAt: null,
54
+ isQuotaError: false,
55
+ circuitState: 'CLOSED',
56
+ lastUsed: 0,
57
+ successCount: 0,
58
+ totalRequests: 0,
59
+ halfOpenTestTime: null,
60
+ customCooldown: null,
61
+ }));
62
+ this.loadState();
63
+ }
64
+ /**
65
+ * CLASSIFIES an error to determine handling strategy
66
+ */
67
+ classifyError(error, finishReason) {
68
+ const status = error?.status || error?.response?.status;
69
+ const message = error?.message || error?.error?.message || String(error);
70
+ // 1. Check finishReason first (for 200 responses with content issues)
71
+ if (finishReason === 'SAFETY') {
72
+ return { type: 'SAFETY', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
73
+ }
74
+ if (finishReason === 'RECITATION') {
75
+ return { type: 'RECITATION', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
76
+ }
77
+ // 2. Check HTTP status codes
78
+ if (status === 403 || ERROR_PATTERNS.isAuthError.test(message)) {
79
+ return { type: 'AUTH', retryable: false, cooldownMs: Infinity, markKeyFailed: true, markKeyDead: true };
80
+ }
81
+ if (status === 429 || ERROR_PATTERNS.isQuotaError.test(message)) {
82
+ const retryAfter = this.parseRetryAfter(error);
83
+ return {
84
+ type: 'QUOTA',
85
+ retryable: true,
86
+ cooldownMs: retryAfter || CONFIG.COOLDOWN_QUOTA,
87
+ markKeyFailed: true,
88
+ markKeyDead: false
89
+ };
90
+ }
91
+ if (status === 400 || ERROR_PATTERNS.isBadRequest.test(message)) {
92
+ return { type: 'BAD_REQUEST', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
93
+ }
94
+ if (ERROR_PATTERNS.isTransient.test(message) || [500, 502, 503, 504].includes(status)) {
95
+ return { type: 'TRANSIENT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
96
+ }
97
+ return { type: 'UNKNOWN', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
98
+ }
99
+ /**
100
+ * Parses Retry-After header from error response
101
+ */
102
+ parseRetryAfter(error) {
103
+ const retryAfter = error?.response?.headers?.['retry-after'] ||
104
+ error?.headers?.['retry-after'] ||
105
+ error?.retryAfter;
106
+ if (!retryAfter)
107
+ return null;
108
+ // If it's a number (seconds)
109
+ const seconds = parseInt(retryAfter, 10);
110
+ if (!isNaN(seconds))
111
+ return seconds * 1000;
112
+ // If it's a date string
113
+ const date = Date.parse(retryAfter);
114
+ if (!isNaN(date))
115
+ return Math.max(0, date - Date.now());
116
+ return null;
117
+ }
118
+ /**
119
+ * HEALTH CHECK
120
+ * Determines if a key is usable based on Circuit Breaker logic
121
+ */
122
+ isOnCooldown(k) {
123
+ // Dead keys are NEVER usable
124
+ if (k.circuitState === 'DEAD')
125
+ return true;
126
+ const now = Date.now();
127
+ if (k.circuitState === 'OPEN') {
128
+ // Check if ready for HALF_OPEN test
129
+ if (k.halfOpenTestTime && now >= k.halfOpenTestTime) {
130
+ k.circuitState = 'HALF_OPEN';
131
+ return false;
132
+ }
133
+ return true;
134
+ }
135
+ // Additional safeguard for custom cooldowns
136
+ if (k.failedAt && k.customCooldown) {
137
+ if (now - k.failedAt < k.customCooldown)
138
+ return true;
139
+ }
140
+ // Standard cooldown check
141
+ if (k.failedAt) {
142
+ const cooldown = k.isQuotaError ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT;
143
+ if (now - k.failedAt < cooldown)
144
+ return true;
145
+ }
146
+ return false;
147
+ }
148
+ /**
149
+ * CORE ROTATION LOGIC
150
+ * Returns the best available key
151
+ */
152
+ getKey() {
153
+ // 1. Filter out dead and cooling down keys
154
+ const candidates = this.keys.filter(k => k.circuitState !== 'DEAD' && !this.isOnCooldown(k));
155
+ if (candidates.length === 0) {
156
+ // FALLBACK: Return oldest failed key (excluding DEAD)
157
+ const nonDead = this.keys.filter(k => k.circuitState !== 'DEAD');
158
+ if (nonDead.length === 0)
159
+ return null; // All keys are dead!
160
+ return nonDead.sort((a, b) => (a.failedAt || 0) - (b.failedAt || 0))[0]?.key || null;
161
+ }
162
+ // 2. Sort candidates: Pristine > Fewest Failures > Least Recently Used
163
+ candidates.sort((a, b) => {
164
+ if (a.failCount !== b.failCount)
165
+ return a.failCount - b.failCount;
166
+ return a.lastUsed - b.lastUsed;
167
+ });
168
+ const selected = candidates[0];
169
+ selected.lastUsed = Date.now();
170
+ this.saveState();
171
+ return selected.key;
172
+ }
173
+ /**
174
+ * Get count of healthy (non-DEAD) keys
175
+ */
176
+ getKeyCount() {
177
+ return this.keys.filter(k => k.circuitState !== 'DEAD').length;
178
+ }
179
+ /**
180
+ * FEEDBACK LOOP: Success
181
+ */
182
+ markSuccess(key) {
183
+ const k = this.keys.find(x => x.key === key);
184
+ if (!k)
185
+ return;
186
+ if (k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD') {
187
+ console.log(`[Key Recovered] ...${key.slice(-4)}`);
188
+ }
189
+ k.circuitState = 'CLOSED';
190
+ k.failCount = 0;
191
+ k.failedAt = null;
192
+ k.isQuotaError = false;
193
+ k.customCooldown = null;
194
+ k.successCount++;
195
+ k.totalRequests++;
196
+ this.saveState();
197
+ }
198
+ /**
199
+ * FEEDBACK LOOP: Failure
200
+ * Enhanced with error classification
201
+ */
202
+ markFailed(key, classification) {
203
+ const k = this.keys.find(x => x.key === key);
204
+ if (!k)
205
+ return;
206
+ // Don't modify DEAD keys
207
+ if (k.circuitState === 'DEAD')
208
+ return;
209
+ // If this error shouldn't mark the key as failed, skip
210
+ if (!classification.markKeyFailed)
211
+ return;
212
+ k.failedAt = Date.now();
213
+ k.failCount++;
214
+ k.totalRequests++;
215
+ k.isQuotaError = classification.type === 'QUOTA';
216
+ k.customCooldown = classification.cooldownMs || null;
217
+ // Permanent death for auth errors
218
+ if (classification.markKeyDead) {
219
+ k.circuitState = 'DEAD';
220
+ console.error(`[Key DEAD] ...${key.slice(-4)} - Permanently removed from rotation`);
221
+ this.saveState();
222
+ return;
223
+ }
224
+ // State Transitions
225
+ if (k.circuitState === 'HALF_OPEN') {
226
+ k.circuitState = 'OPEN';
227
+ k.halfOpenTestTime = Date.now() + CONFIG.HALF_OPEN_TEST_DELAY;
228
+ }
229
+ else if (k.failCount >= CONFIG.MAX_CONSECUTIVE_FAILURES || classification.type === 'QUOTA') {
230
+ k.circuitState = 'OPEN';
231
+ k.halfOpenTestTime = Date.now() + (classification.cooldownMs || CONFIG.HALF_OPEN_TEST_DELAY);
232
+ }
233
+ this.saveState();
234
+ }
235
+ /**
236
+ * Legacy markFailed for backward compatibility
237
+ */
238
+ markFailedLegacy(key, isQuota = false) {
239
+ this.markFailed(key, {
240
+ type: isQuota ? 'QUOTA' : 'TRANSIENT',
241
+ retryable: true,
242
+ cooldownMs: isQuota ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT,
243
+ markKeyFailed: true,
244
+ markKeyDead: false,
245
+ });
246
+ }
247
+ /**
248
+ * Calculate backoff delay with jitter
249
+ */
250
+ calculateBackoff(attempt) {
251
+ const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
252
+ const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
253
+ const jitter = Math.random() * 1000;
254
+ return capped + jitter;
255
+ }
256
+ // ... inside class ...
257
+ /**
258
+ * Get health statistics
259
+ */
260
+ getStats() {
261
+ const total = this.keys.length;
262
+ const dead = this.keys.filter(k => k.circuitState === 'DEAD').length;
263
+ const cooling = this.keys.filter(k => k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN').length;
264
+ const healthy = total - dead - cooling;
265
+ return { total, healthy, cooling, dead };
266
+ }
267
+ // Helper for testing
268
+ _getKeys() {
269
+ return this.keys;
270
+ }
271
+ saveState() {
272
+ if (!this.storage)
273
+ return;
274
+ const state = this.keys.reduce((acc, k) => ({
275
+ ...acc,
276
+ [k.key]: {
277
+ failCount: k.failCount,
278
+ failedAt: k.failedAt,
279
+ isQuotaError: k.isQuotaError,
280
+ circuitState: k.circuitState,
281
+ lastUsed: k.lastUsed,
282
+ successCount: k.successCount,
283
+ totalRequests: k.totalRequests,
284
+ customCooldown: k.customCooldown,
285
+ }
286
+ }), {});
287
+ this.storage.setItem(this.storageKey, JSON.stringify(state));
288
+ }
289
+ loadState() {
290
+ if (!this.storage)
291
+ return;
292
+ try {
293
+ const raw = this.storage.getItem(this.storageKey);
294
+ if (!raw)
295
+ return;
296
+ const data = JSON.parse(raw);
297
+ this.keys.forEach(k => {
298
+ if (data[k.key])
299
+ Object.assign(k, data[k.key]);
300
+ });
301
+ }
302
+ catch (e) {
303
+ console.error("Failed to load key state");
304
+ }
305
+ }
306
+ }
307
+ exports.ApiKeyManager = ApiKeyManager;
308
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@splashcodex/api-key-manager",
3
+ "version": "1.0.0",
4
+ "description": "Universal API Key Rotation System for rate-limited APIs",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "src"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "ts-node tests/index.test.ts",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "keywords": [
17
+ "api",
18
+ "key",
19
+ "rotation",
20
+ "gemini",
21
+ "rate-limit",
22
+ "circuit-breaker"
23
+ ],
24
+ "author": "Antigravity",
25
+ "license": "ISC",
26
+ "devDependencies": {
27
+ "typescript": "^5.3.3",
28
+ "ts-node": "^10.9.2",
29
+ "@types/node": "^20.10.5"
30
+ }
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Universal ApiKeyManager v2.0
3
+ * Implements: Rotation, Circuit Breaker, Persistence, Exponential Backoff
4
+ * Gemini-Specific: finishReason handling, Safety blocks, RECITATION detection
5
+ */
6
+
7
+ export interface KeyState {
8
+ key: string;
9
+ failCount: number; // Consecutive failures
10
+ failedAt: number | null; // Timestamp of last failure
11
+ isQuotaError: boolean; // Was last error a 429?
12
+ circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN' | 'DEAD';
13
+ lastUsed: number;
14
+ successCount: number;
15
+ totalRequests: number;
16
+ halfOpenTestTime: number | null;
17
+ customCooldown: number | null; // From Retry-After header
18
+ }
19
+
20
+ export type ErrorType =
21
+ | 'QUOTA' // 429 - Rotate key, respect cooldown
22
+ | 'TRANSIENT' // 500/503/504 - Retry with backoff
23
+ | 'AUTH' // 403 - Key is dead, remove from pool
24
+ | 'BAD_REQUEST' // 400 - Do not retry, fix request
25
+ | 'SAFETY' // finishReason: SAFETY - Not a key issue
26
+ | 'RECITATION' // finishReason: RECITATION - Not a key issue
27
+ | 'UNKNOWN'; // Catch-all
28
+
29
+ export interface ErrorClassification {
30
+ type: ErrorType;
31
+ retryable: boolean;
32
+ cooldownMs: number;
33
+ markKeyFailed: boolean;
34
+ markKeyDead: boolean;
35
+ }
36
+
37
+ const CONFIG = {
38
+ MAX_CONSECUTIVE_FAILURES: 5,
39
+ COOLDOWN_TRANSIENT: 60 * 1000, // 1 minute
40
+ COOLDOWN_QUOTA: 5 * 60 * 1000, // 5 minutes (default if no Retry-After)
41
+ COOLDOWN_QUOTA_DAILY: 60 * 60 * 1000, // 1 hour for RPD exhaustion
42
+ HALF_OPEN_TEST_DELAY: 60 * 1000, // 1 minute after open
43
+ MAX_BACKOFF: 64 * 1000, // 64 seconds max
44
+ BASE_BACKOFF: 1000, // 1 second base
45
+ };
46
+
47
+ // Error classification patterns
48
+ const ERROR_PATTERNS = {
49
+ isQuotaError: /429|quota|exhausted|resource.?exhausted|too.?many.?requests|rate.?limit/i,
50
+ isAuthError: /403|permission.?denied|invalid.?api.?key|unauthorized|unauthenticated/i,
51
+ isSafetyBlock: /safety|blocked|recitation|harmful/i,
52
+ isTransient: /500|502|503|504|internal|unavailable|deadline|timeout|overloaded/i,
53
+ isBadRequest: /400|invalid.?argument|failed.?precondition|malformed|not.?found|404/i,
54
+ };
55
+
56
+ export interface ApiKeyManagerStats {
57
+ total: number;
58
+ healthy: number;
59
+ cooling: number;
60
+ dead: number;
61
+ }
62
+
63
+ export class ApiKeyManager {
64
+ private keys: KeyState[] = [];
65
+ private storageKey = 'api_rotation_state_v2';
66
+ // Simplified Storage interface for Node.js environment
67
+ private storage: any;
68
+
69
+ constructor(initialKeys: string[], storage?: any) {
70
+ // Simple in-memory storage mock if none provided (for testing/Node)
71
+ this.storage = storage || {
72
+ getItem: () => null,
73
+ setItem: () => { },
74
+ };
75
+
76
+ // 1. Sanitize & Deduplicate Keys "Even Better"
77
+ const uniqueKeys = new Set<string>();
78
+ initialKeys.forEach(rawKey => {
79
+ // Handle comma-separated keys if they appear in a single string
80
+ const parts = rawKey.split(',').map(s => s.trim()).filter(s => s.length > 0);
81
+ parts.forEach(p => uniqueKeys.add(p));
82
+ });
83
+
84
+ // 2. Warn about duplicates/empty
85
+ const sanitizedCount = uniqueKeys.size;
86
+ const rawLength = initialKeys.length; // Might be misleading if split happened, but roughly
87
+ if (sanitizedCount < rawLength) {
88
+ console.warn(`[ApiKeyManager] Optimized key pool: Removed ${rawLength - sanitizedCount} duplicate/empty keys.`);
89
+ }
90
+
91
+ this.keys = Array.from(uniqueKeys).map(k => ({
92
+ key: k,
93
+ failCount: 0,
94
+ failedAt: null,
95
+ isQuotaError: false,
96
+ circuitState: 'CLOSED',
97
+ lastUsed: 0,
98
+ successCount: 0,
99
+ totalRequests: 0,
100
+ halfOpenTestTime: null,
101
+ customCooldown: null,
102
+ }));
103
+
104
+ this.loadState();
105
+ }
106
+
107
+ /**
108
+ * CLASSIFIES an error to determine handling strategy
109
+ */
110
+ public classifyError(error: any, finishReason?: string): ErrorClassification {
111
+ const status = error?.status || error?.response?.status;
112
+ const message = error?.message || error?.error?.message || String(error);
113
+
114
+ // 1. Check finishReason first (for 200 responses with content issues)
115
+ if (finishReason === 'SAFETY') {
116
+ return { type: 'SAFETY', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
117
+ }
118
+ if (finishReason === 'RECITATION') {
119
+ return { type: 'RECITATION', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
120
+ }
121
+
122
+ // 2. Check HTTP status codes
123
+ if (status === 403 || ERROR_PATTERNS.isAuthError.test(message)) {
124
+ return { type: 'AUTH', retryable: false, cooldownMs: Infinity, markKeyFailed: true, markKeyDead: true };
125
+ }
126
+ if (status === 429 || ERROR_PATTERNS.isQuotaError.test(message)) {
127
+ const retryAfter = this.parseRetryAfter(error);
128
+ return {
129
+ type: 'QUOTA',
130
+ retryable: true,
131
+ cooldownMs: retryAfter || CONFIG.COOLDOWN_QUOTA,
132
+ markKeyFailed: true,
133
+ markKeyDead: false
134
+ };
135
+ }
136
+ if (status === 400 || ERROR_PATTERNS.isBadRequest.test(message)) {
137
+ return { type: 'BAD_REQUEST', retryable: false, cooldownMs: 0, markKeyFailed: false, markKeyDead: false };
138
+ }
139
+ if (ERROR_PATTERNS.isTransient.test(message) || [500, 502, 503, 504].includes(status)) {
140
+ return { type: 'TRANSIENT', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
141
+ }
142
+
143
+ return { type: 'UNKNOWN', retryable: true, cooldownMs: CONFIG.COOLDOWN_TRANSIENT, markKeyFailed: true, markKeyDead: false };
144
+ }
145
+
146
+ /**
147
+ * Parses Retry-After header from error response
148
+ */
149
+ private parseRetryAfter(error: any): number | null {
150
+ const retryAfter = error?.response?.headers?.['retry-after'] ||
151
+ error?.headers?.['retry-after'] ||
152
+ error?.retryAfter;
153
+
154
+ if (!retryAfter) return null;
155
+
156
+ // If it's a number (seconds)
157
+ const seconds = parseInt(retryAfter, 10);
158
+ if (!isNaN(seconds)) return seconds * 1000;
159
+
160
+ // If it's a date string
161
+ const date = Date.parse(retryAfter);
162
+ if (!isNaN(date)) return Math.max(0, date - Date.now());
163
+
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * HEALTH CHECK
169
+ * Determines if a key is usable based on Circuit Breaker logic
170
+ */
171
+ private isOnCooldown(k: KeyState): boolean {
172
+ // Dead keys are NEVER usable
173
+ if (k.circuitState === 'DEAD') return true;
174
+
175
+ const now = Date.now();
176
+
177
+ if (k.circuitState === 'OPEN') {
178
+ // Check if ready for HALF_OPEN test
179
+ if (k.halfOpenTestTime && now >= k.halfOpenTestTime) {
180
+ k.circuitState = 'HALF_OPEN';
181
+ return false;
182
+ }
183
+ return true;
184
+ }
185
+
186
+ // Additional safeguard for custom cooldowns
187
+ if (k.failedAt && k.customCooldown) {
188
+ if (now - k.failedAt < k.customCooldown) return true;
189
+ }
190
+
191
+ // Standard cooldown check
192
+ if (k.failedAt) {
193
+ const cooldown = k.isQuotaError ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT;
194
+ if (now - k.failedAt < cooldown) return true;
195
+ }
196
+
197
+ return false;
198
+ }
199
+
200
+ /**
201
+ * CORE ROTATION LOGIC
202
+ * Returns the best available key
203
+ */
204
+ public getKey(): string | null {
205
+ // 1. Filter out dead and cooling down keys
206
+ const candidates = this.keys.filter(k =>
207
+ k.circuitState !== 'DEAD' && !this.isOnCooldown(k)
208
+ );
209
+
210
+ if (candidates.length === 0) {
211
+ // FALLBACK: Return oldest failed key (excluding DEAD)
212
+ const nonDead = this.keys.filter(k => k.circuitState !== 'DEAD');
213
+ if (nonDead.length === 0) return null; // All keys are dead!
214
+
215
+ return nonDead.sort((a, b) => (a.failedAt || 0) - (b.failedAt || 0))[0]?.key || null;
216
+ }
217
+
218
+ // 2. Sort candidates: Pristine > Fewest Failures > Least Recently Used
219
+ candidates.sort((a, b) => {
220
+ if (a.failCount !== b.failCount) return a.failCount - b.failCount;
221
+ return a.lastUsed - b.lastUsed;
222
+ });
223
+
224
+ const selected = candidates[0];
225
+ selected.lastUsed = Date.now();
226
+ this.saveState();
227
+
228
+ return selected.key;
229
+ }
230
+
231
+ /**
232
+ * Get count of healthy (non-DEAD) keys
233
+ */
234
+ public getKeyCount(): number {
235
+ return this.keys.filter(k => k.circuitState !== 'DEAD').length;
236
+ }
237
+
238
+ /**
239
+ * FEEDBACK LOOP: Success
240
+ */
241
+ public markSuccess(key: string) {
242
+ const k = this.keys.find(x => x.key === key);
243
+ if (!k) return;
244
+
245
+ if (k.circuitState !== 'CLOSED' && k.circuitState !== 'DEAD') {
246
+ console.log(`[Key Recovered] ...${key.slice(-4)}`);
247
+ }
248
+
249
+ k.circuitState = 'CLOSED';
250
+ k.failCount = 0;
251
+ k.failedAt = null;
252
+ k.isQuotaError = false;
253
+ k.customCooldown = null;
254
+ k.successCount++;
255
+ k.totalRequests++;
256
+
257
+ this.saveState();
258
+ }
259
+
260
+ /**
261
+ * FEEDBACK LOOP: Failure
262
+ * Enhanced with error classification
263
+ */
264
+ public markFailed(key: string, classification: ErrorClassification) {
265
+ const k = this.keys.find(x => x.key === key);
266
+ if (!k) return;
267
+
268
+ // Don't modify DEAD keys
269
+ if (k.circuitState === 'DEAD') return;
270
+
271
+ // If this error shouldn't mark the key as failed, skip
272
+ if (!classification.markKeyFailed) return;
273
+
274
+ k.failedAt = Date.now();
275
+ k.failCount++;
276
+ k.totalRequests++;
277
+ k.isQuotaError = classification.type === 'QUOTA';
278
+ k.customCooldown = classification.cooldownMs || null;
279
+
280
+ // Permanent death for auth errors
281
+ if (classification.markKeyDead) {
282
+ k.circuitState = 'DEAD';
283
+ console.error(`[Key DEAD] ...${key.slice(-4)} - Permanently removed from rotation`);
284
+ this.saveState();
285
+ return;
286
+ }
287
+
288
+ // State Transitions
289
+ if (k.circuitState === 'HALF_OPEN') {
290
+ k.circuitState = 'OPEN';
291
+ k.halfOpenTestTime = Date.now() + CONFIG.HALF_OPEN_TEST_DELAY;
292
+ } else if (k.failCount >= CONFIG.MAX_CONSECUTIVE_FAILURES || classification.type === 'QUOTA') {
293
+ k.circuitState = 'OPEN';
294
+ k.halfOpenTestTime = Date.now() + (classification.cooldownMs || CONFIG.HALF_OPEN_TEST_DELAY);
295
+ }
296
+
297
+ this.saveState();
298
+ }
299
+
300
+ /**
301
+ * Legacy markFailed for backward compatibility
302
+ */
303
+ public markFailedLegacy(key: string, isQuota: boolean = false) {
304
+ this.markFailed(key, {
305
+ type: isQuota ? 'QUOTA' : 'TRANSIENT',
306
+ retryable: true,
307
+ cooldownMs: isQuota ? CONFIG.COOLDOWN_QUOTA : CONFIG.COOLDOWN_TRANSIENT,
308
+ markKeyFailed: true,
309
+ markKeyDead: false,
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Calculate backoff delay with jitter
315
+ */
316
+ public calculateBackoff(attempt: number): number {
317
+ const exponential = CONFIG.BASE_BACKOFF * Math.pow(2, attempt);
318
+ const capped = Math.min(exponential, CONFIG.MAX_BACKOFF);
319
+ const jitter = Math.random() * 1000;
320
+ return capped + jitter;
321
+ }
322
+
323
+
324
+
325
+ // ... inside class ...
326
+
327
+ /**
328
+ * Get health statistics
329
+ */
330
+ public getStats(): ApiKeyManagerStats {
331
+ const total = this.keys.length;
332
+ const dead = this.keys.filter(k => k.circuitState === 'DEAD').length;
333
+ const cooling = this.keys.filter(k => k.circuitState === 'OPEN' || k.circuitState === 'HALF_OPEN').length;
334
+ const healthy = total - dead - cooling;
335
+ return { total, healthy, cooling, dead };
336
+ }
337
+
338
+ // Helper for testing
339
+ public _getKeys(): KeyState[] {
340
+ return this.keys;
341
+ }
342
+
343
+ private saveState() {
344
+ if (!this.storage) return;
345
+ const state = this.keys.reduce((acc, k) => ({
346
+ ...acc,
347
+ [k.key]: {
348
+ failCount: k.failCount,
349
+ failedAt: k.failedAt,
350
+ isQuotaError: k.isQuotaError,
351
+ circuitState: k.circuitState,
352
+ lastUsed: k.lastUsed,
353
+ successCount: k.successCount,
354
+ totalRequests: k.totalRequests,
355
+ customCooldown: k.customCooldown,
356
+ }
357
+ }), {});
358
+ this.storage.setItem(this.storageKey, JSON.stringify(state));
359
+ }
360
+
361
+ private loadState() {
362
+ if (!this.storage) return;
363
+ try {
364
+ const raw = this.storage.getItem(this.storageKey);
365
+ if (!raw) return;
366
+ const data = JSON.parse(raw);
367
+ this.keys.forEach(k => {
368
+ if (data[k.key]) Object.assign(k, data[k.key]);
369
+ });
370
+ } catch (e) {
371
+ console.error("Failed to load key state");
372
+ }
373
+ }
374
+ }